Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64391503c2 | |||
| 92b75d1926 | |||
| e072d0c894 | |||
| 3a78a84610 | |||
| 8f01588221 | |||
| f6fc836e79 | |||
| 9c303e0a82 | |||
| cb8e46919f | |||
| 279806da2b | |||
| 90fa487d1b | |||
| b58bb267a1 | |||
| 6af76c4d80 |
@@ -1,137 +0,0 @@
|
||||
# 🎉 PolyHermes v1.1.7 发布公告
|
||||
|
||||
**发布日期:2026年1月7日**
|
||||
|
||||
---
|
||||
|
||||
## ✨ 新功能
|
||||
|
||||
### 💰 支持 Maker Rebates Program 费率
|
||||
|
||||
我们新增了对 Polymarket Maker Rebates Program 的支持!系统现在会自动获取并应用最新的费率,帮助您享受更优惠的交易成本。
|
||||
|
||||
**这意味着什么?**
|
||||
- 系统会自动查询并应用最新的费率
|
||||
- 所有订单(买入、卖出)都会使用正确的费率
|
||||
- 无需手动配置,系统会自动处理
|
||||
|
||||
### 🔧 Docker 部署更灵活
|
||||
|
||||
现在您可以通过环境变量轻松配置日志级别,无需修改配置文件!
|
||||
|
||||
**新增配置项:**
|
||||
- `LOG_LEVEL_ROOT` - 系统日志级别(默认:INFO)
|
||||
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
|
||||
|
||||
只需在 `.env` 文件中添加这些配置,重启服务即可生效。
|
||||
|
||||
---
|
||||
|
||||
## 🐛 问题修复
|
||||
|
||||
### 修复市场价格查询问题
|
||||
|
||||
修复了某些市场无法正确查询价格的问题。现在系统会:
|
||||
- 优先从链上查询市场价格
|
||||
- 如果链上查询失败,自动降级到其他数据源
|
||||
- 提高系统的稳定性和容错性
|
||||
|
||||
### 修复自动卖出误判问题
|
||||
|
||||
修复了在某些情况下系统会误判市场已卖出,导致创建错误记录的问题。现在系统会:
|
||||
- 更准确地判断市场状态
|
||||
- 避免误判导致的错误记录
|
||||
- 提高仓位管理的准确性
|
||||
|
||||
---
|
||||
|
||||
## 📝 文档更新
|
||||
|
||||
### 更新联系方式
|
||||
|
||||
- **Telegram 群组**:https://t.me/polyhermes
|
||||
- 欢迎加入我们的 Telegram 群组,获取最新资讯和技术支持!
|
||||
|
||||
### 版本信息更清晰
|
||||
|
||||
- README 中新增了 Docker 版本徽章
|
||||
- 可以一目了然地看到最新的 Docker 镜像版本
|
||||
|
||||
---
|
||||
|
||||
## 📊 本次更新统计
|
||||
|
||||
- **5 个提交**
|
||||
- **16 个文件变更**
|
||||
- **主要改进**:费率支持、错误修复、部署优化
|
||||
|
||||
---
|
||||
|
||||
## 🚀 如何升级
|
||||
|
||||
### Docker 部署用户(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 停止当前服务
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# 2. 拉取最新镜像
|
||||
docker pull wrbug/polyhermes:latest
|
||||
|
||||
# 3. 重新启动服务
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 4. 查看日志确认升级成功
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
```
|
||||
|
||||
### 本地构建用户
|
||||
|
||||
```bash
|
||||
# 1. 拉取最新代码
|
||||
git pull origin dev
|
||||
|
||||
# 2. 切换到 v1.1.7 标签
|
||||
git checkout v1.1.7
|
||||
|
||||
# 3. 重新构建并启动
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要提示
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
**本次更新无需数据库迁移**,可以直接升级,不会影响现有数据。
|
||||
|
||||
### 配置变更
|
||||
|
||||
- 新增的日志级别配置为**可选配置**
|
||||
- 如果不配置,系统会使用默认值(INFO/DEBUG)
|
||||
- 不影响现有功能
|
||||
|
||||
---
|
||||
|
||||
## 🙏 感谢
|
||||
|
||||
感谢所有用户的支持和反馈!如果您在使用过程中遇到任何问题,欢迎:
|
||||
|
||||
- 📧 提交 Issue:https://github.com/WrBug/PolyHermes/issues
|
||||
- 💬 加入 Telegram 群组:https://t.me/polyhermes
|
||||
- 🐦 关注 Twitter:@polyhermes
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关链接
|
||||
|
||||
- **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
- **完整更新日志**:https://github.com/WrBug/PolyHermes/releases/tag/v1.1.7
|
||||
- **Docker Hub**:https://hub.docker.com/r/wrbug/polyhermes
|
||||
|
||||
---
|
||||
|
||||
**祝您交易顺利!** 🚀
|
||||
|
||||
|
||||
+2
-2
@@ -47,9 +47,9 @@ FROM eclipse-temurin:17-jre-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 Nginx 和必要的工具
|
||||
# 安装 Nginx 和必要的工具(包含时区数据)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y nginx curl && \
|
||||
apt-get install -y nginx curl tzdata && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
rm -rf /etc/nginx/sites-enabled/default
|
||||
|
||||
|
||||
+220
@@ -1,3 +1,223 @@
|
||||
# v1.1.10
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 📢 推送已过滤订单功能
|
||||
|
||||
- **新增推送已过滤订单功能(pushFilteredOrders)**,默认关闭
|
||||
- 支持在模板和跟单配置中配置是否推送被过滤的订单通知
|
||||
- 开启后,当订单因过滤条件(价格区间、订单深度、价差、仓位限制等)被过滤时,会发送 Telegram 通知
|
||||
- 帮助用户了解哪些订单被过滤以及过滤原因
|
||||
|
||||
- **数据库迁移**:
|
||||
- 添加 `push_filtered_orders` 字段到 `copy_trading_templates` 表
|
||||
- 添加 `push_filtered_orders` 字段到 `copy_trading` 表
|
||||
- 迁移脚本:`V24__add_push_filtered_orders_to_templates.sql`
|
||||
|
||||
- **后端实现**:
|
||||
- 在实体类(`CopyTradingTemplate`、`CopyTrading`)中添加 `pushFilteredOrders` 字段
|
||||
- 在 DTO 中添加字段支持(创建、更新、查询)
|
||||
- 在 Service 中处理字段的创建、更新和传递
|
||||
- 在发送过滤订单通知时检查 `pushFilteredOrders` 字段,只有为 `true` 时才发送
|
||||
|
||||
- **前端实现**:
|
||||
- 在模板新增、编辑、推送页面添加"推送已过滤订单"开关
|
||||
- 在跟单配置新增、编辑页面添加"推送已过滤订单"开关
|
||||
- 从模板创建跟单配置时,自动填充 `pushFilteredOrders` 字段
|
||||
- 支持多语言(中文、繁体中文、英文)
|
||||
|
||||
### 🔍 优化订单列表筛选功能
|
||||
|
||||
- **支持按市场标题搜索**:
|
||||
- 买入订单列表:添加市场标题筛选,移除方向筛选
|
||||
- 卖出订单列表:添加市场标题筛选,将方向筛选改为状态筛选
|
||||
- 已成交订单列表:添加市场标题筛选和市场列显示
|
||||
|
||||
- **改进分组体验**:
|
||||
- 记录用户的分组偏好到 localStorage,跨会话持久化
|
||||
- 所有搜索输入框添加 0.5 秒防抖优化,提升性能
|
||||
|
||||
- **后端优化**:
|
||||
- `MarketGroupedOrdersRequest` 添加 `marketId` 和 `marketTitle` 字段
|
||||
- 买入/卖出订单分组接口支持市场 ID 模糊匹配和市场标题关键字筛选
|
||||
|
||||
### ⚡ 优化跟单关系统计性能
|
||||
|
||||
- **移除未实现盈亏计算**:
|
||||
- 移除未实现盈亏和持仓价值的计算逻辑
|
||||
- 总盈亏现在仅包含已实现盈亏,计算更准确
|
||||
- 简化盈亏百分比计算,仅基于已实现盈亏
|
||||
|
||||
- **性能提升**:
|
||||
- 从 1+N 次网络请求减少到 0 次
|
||||
- 删除不再使用的方法和依赖注入
|
||||
- 统计查询速度显著提升
|
||||
|
||||
### 🐳 Docker 容器时区配置
|
||||
|
||||
- **支持通过 .env 自定义时区**:
|
||||
- 在 `docker-compose.yml` 和 `docker-compose.prod.yml` 中添加时区环境变量配置
|
||||
- 支持通过 `TZ` 环境变量自定义容器时区
|
||||
- 默认使用系统时区
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复跟单配置更新时清空可选字段无法保存的问题
|
||||
|
||||
- **问题**:修改跟单配置时,清空价格区间、最大仓位数量、截止时间等可选字段后,无法保存到数据库
|
||||
- **修复**:
|
||||
- 修复价格区间(`minPrice`/`maxPrice`)清空后无法保存的问题
|
||||
- 修复最大仓位数量(`maxPositionCount`)清空后无法保存的问题
|
||||
- 修复市场截止时间(`maxMarketEndDate`)清空后无法保存的问题
|
||||
- 修复其他可选字段(`minOrderDepth`/`maxSpread`/`maxPositionValue`)清空后无法保存的问题
|
||||
|
||||
- **实现方案**:
|
||||
- 前端:清空字段时传空字符串或 `-1` 标记,让后端识别为清空操作
|
||||
- 后端:处理空字符串和 `-1` 标记,正确设置为 `null` 以清空字段
|
||||
|
||||
### 修复新建和编辑页面的截止时间输入框交互问题
|
||||
|
||||
- **问题**:删除截止时间输入框内容后,失去焦点会自动填充 1
|
||||
- **修复**:
|
||||
- 将 `min` 从 `1` 改为 `0`,允许空值
|
||||
- 优化 `onChange` 处理,当值为 `0`、`null` 或 `undefined` 时设置为 `undefined`(清空)
|
||||
- 添加 `onBlur` 处理,确保失去焦点时如果值为空或 `0`,设置为 `undefined`
|
||||
- 修改 `parser`,空值时返回空字符串而不是 `0`
|
||||
|
||||
### 修复订单通知重复发送和时间显示问题
|
||||
|
||||
- **修复并发导致的重复通知问题**:
|
||||
- 在 `OrderStatusUpdateService` 中实现双重检查机制
|
||||
- 先保存订单标记为已发送,再重新查询数据库检查
|
||||
- 防止定时任务并发时重复发送同一订单的通知
|
||||
- 同时修复买入订单和卖出订单的通知逻辑
|
||||
|
||||
- **修复 Telegram 通知时间显示**:
|
||||
- `TelegramNotificationService.sendOrderSuccessNotification` 添加 `orderTime` 参数
|
||||
- 使用订单的 `createdAt` 时间戳作为通知显示时间
|
||||
- 而不是使用当前通知发送时间
|
||||
- 更准确反映订单的实际创建时间
|
||||
|
||||
### 修复新建跟单配置时 pushFilteredOrders 字段未生效的问题
|
||||
|
||||
- **问题**:新建跟单配置时,即使设置了 `pushFilteredOrders: true`,也没有生效
|
||||
- **修复**:
|
||||
- 修复手动输入模式下 `pushFilteredOrders` 被硬编码为 `false` 的问题
|
||||
- 修复从模板填充表单时未加载 `pushFilteredOrders` 的问题
|
||||
- 添加 `CopyTradingTemplate` 接口的 `pushFilteredOrders` 字段定义
|
||||
|
||||
## 📝 技术细节
|
||||
|
||||
### 数据库变更
|
||||
|
||||
- **迁移脚本**:`V24__add_push_filtered_orders_to_templates.sql`
|
||||
- **变更内容**:
|
||||
- `copy_trading_templates.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- `copy_trading.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- **自动执行**:升级时会自动执行迁移脚本
|
||||
|
||||
### API 变更
|
||||
|
||||
- **无新增接口**
|
||||
- **无移除接口**
|
||||
- **请求/响应变更**:
|
||||
- `CopyTradingCreateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `CopyTradingUpdateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `TemplateCreateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `TemplateUpdateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `MarketGroupedOrdersRequest` 添加 `marketId` 和 `marketTitle` 字段
|
||||
|
||||
### 前端变更
|
||||
|
||||
- **新增字段**:
|
||||
- `CopyTradingTemplate` 接口添加 `pushFilteredOrders` 字段
|
||||
- **组件更新**:
|
||||
- 模板新增、编辑、推送页面添加"推送已过滤订单"开关
|
||||
- 跟单配置新增、编辑页面添加"推送已过滤订单"开关
|
||||
- 优化截止时间输入框交互逻辑
|
||||
- **多语言支持**:
|
||||
- 添加中文、繁体中文、英文翻译
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:6 个提交
|
||||
- **文件变更**:30 个文件
|
||||
- **代码变更**:+651 行 / -555 行(净增加 96 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `CopyTrading.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
|
||||
- `CopyTradingTemplate.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
|
||||
- `CopyTradingDto.kt` - 添加 `pushFilteredOrders` 字段支持(+3 行)
|
||||
- `CopyTradingTemplateDto.kt` - 添加 `pushFilteredOrders` 字段支持(+10 行)
|
||||
- `CopyTradingService.kt` - 处理 `pushFilteredOrders` 字段和清空字段逻辑(+93 行)
|
||||
- `CopyTradingTemplateService.kt` - 处理 `pushFilteredOrders` 字段(+8 行)
|
||||
- `CopyOrderTrackingService.kt` - 检查 `pushFilteredOrders` 字段发送通知(+42 行)
|
||||
- `CopyTradingStatisticsService.kt` - 优化统计性能(-328 行)
|
||||
- `OrderStatusUpdateService.kt` - 修复重复通知问题(+142 行)
|
||||
- `TelegramNotificationService.kt` - 添加订单时间参数(+17 行)
|
||||
- `V24__add_push_filtered_orders_to_templates.sql` - 数据库迁移脚本(+13 行)
|
||||
|
||||
**前端变更**:
|
||||
- `AddModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+40 行)
|
||||
- `EditModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+59 行)
|
||||
- `TemplateAdd.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
|
||||
- `TemplateEdit.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
|
||||
- `TemplateList.tsx` - 添加 `pushFilteredOrders` 字段(+13 行)
|
||||
- `BuyOrdersTab.tsx` - 优化筛选功能(+94 行)
|
||||
- `SellOrdersTab.tsx` - 优化筛选功能(+77 行)
|
||||
- `MatchedOrdersTab.tsx` - 优化筛选功能(+26 行)
|
||||
- `types/index.ts` - 添加 `pushFilteredOrders` 字段定义(+6 行)
|
||||
- `locales/*/common.json` - 添加多语言翻译(+31 行)
|
||||
|
||||
**配置文件变更**:
|
||||
- `docker-compose.yml` - 添加时区配置(+5 行)
|
||||
- `docker-compose.prod.yml` - 添加时区配置(+5 行)
|
||||
- `Dockerfile` - 优化构建配置(+4 行)
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
9c303e0 feat: 添加推送已过滤订单功能并修复相关问题
|
||||
cb8e469 fix: 修复跟单配置更新时清空可选字段无法保存的问题
|
||||
279806d feat: 优化订单列表筛选功能,支持按市场标题搜索并改进分组体验
|
||||
90fa487 refactor: 移除未实现盈亏计算以优化跟单关系统计性能
|
||||
b58bb26 feat: 添加 Docker 容器时区配置,支持通过 .env 自定义
|
||||
6af76c4 fix: 修复订单通知重复发送和时间显示问题
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:本次版本包含数据库迁移脚本,升级时会自动执行
|
||||
- 自动添加 `push_filtered_orders` 字段到模板表和跟单配置表
|
||||
- 现有数据不受影响,新字段默认值为 `false`
|
||||
|
||||
2. **配置更新**:
|
||||
- 可选:在 `.env` 文件中添加 `TZ` 环境变量自定义容器时区
|
||||
- 无需其他配置变更
|
||||
|
||||
3. **兼容性**:
|
||||
- 完全向后兼容,不影响现有功能
|
||||
- API 变更都是新增字段,不影响现有调用
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.10`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.10)
|
||||
- [完整更新日志](https://github.com/WrBug/PolyHermes/compare/v1.1.9...v1.1.10)
|
||||
|
||||
---
|
||||
|
||||
**发布日期**:2026-01-12
|
||||
|
||||
---
|
||||
|
||||
# v1.1.9
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
@@ -130,7 +130,7 @@ export PROXY_PORT=8888
|
||||
- 代理配置错误
|
||||
|
||||
**排查步骤**:
|
||||
1. 检查 `polymarket.rtds.ws-url` 配置是否正确
|
||||
1. 检查 Polymarket RTDS WebSocket URL(现在使用代码常量 `PolymarketConstants.RTDS_WS_URL`)
|
||||
2. 检查网络连接
|
||||
3. 查看详细错误日志
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
ports:
|
||||
- "${SERVER_PORT:-8000}:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
||||
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
@@ -16,6 +17,8 @@ services:
|
||||
- SERVER_PORT=8000
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -29,12 +32,14 @@ services:
|
||||
ports:
|
||||
- "${MYSQL_PORT:-3306}:3306"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
||||
- MYSQL_DATABASE=polyhermes
|
||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
||||
interval: 10s
|
||||
|
||||
@@ -2,8 +2,8 @@ package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import retrofit2.Retrofit
|
||||
@@ -23,9 +23,6 @@ class RetrofitConfig(
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private lateinit var clobBaseUrl: String
|
||||
|
||||
/**
|
||||
* 创建 CLOB API 客户端
|
||||
* 用于跟单系统的订单操作和交易查询
|
||||
@@ -38,7 +35,7 @@ class RetrofitConfig(
|
||||
val okHttpClient = createClient().build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.wrbug.polymarketbot.constants
|
||||
|
||||
/**
|
||||
* Polymarket API 常量
|
||||
* 集中管理所有 Polymarket API 的 URL 配置
|
||||
*/
|
||||
object PolymarketConstants {
|
||||
|
||||
/**
|
||||
* Polymarket CLOB API 基础 URL
|
||||
*/
|
||||
const val CLOB_BASE_URL = "https://clob.polymarket.com"
|
||||
|
||||
/**
|
||||
* Polymarket RTDS WebSocket URL
|
||||
* 用于订单推送服务
|
||||
*/
|
||||
const val RTDS_WS_URL = "wss://ws-subscriptions-clob.polymarket.com"
|
||||
|
||||
/**
|
||||
* Polymarket User Channel WebSocket URL
|
||||
* 用于跟单服务(订阅 Leader 交易)
|
||||
*/
|
||||
const val USER_WS_URL = "wss://ws-live-data.polymarket.com"
|
||||
|
||||
/**
|
||||
* Polymarket Activity WebSocket URL
|
||||
* 用于 Activity 全局交易流监听
|
||||
*/
|
||||
const val ACTIVITY_WS_URL = "wss://ws-live-data.polymarket.com"
|
||||
|
||||
/**
|
||||
* Polymarket Data API 基础 URL
|
||||
*/
|
||||
const val DATA_API_BASE_URL = "https://data-api.polymarket.com"
|
||||
|
||||
/**
|
||||
* Polymarket Gamma API 基础 URL
|
||||
*/
|
||||
const val GAMMA_BASE_URL = "https://gamma-api.polymarket.com"
|
||||
|
||||
/**
|
||||
* Builder Relayer API URL
|
||||
* 用于 Gasless 交易
|
||||
*/
|
||||
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/"
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ data class CopyTradingCreateRequest(
|
||||
// 新增配置字段
|
||||
val configName: String? = null, // 配置名(可选)
|
||||
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
||||
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
|
||||
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||
)
|
||||
|
||||
@@ -81,6 +82,7 @@ data class CopyTradingUpdateRequest(
|
||||
// 新增配置字段
|
||||
val configName: String? = null, // 配置名(可选,但提供时必须非空)
|
||||
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
||||
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
|
||||
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||
)
|
||||
|
||||
@@ -156,6 +158,7 @@ data class CopyTradingDto(
|
||||
// 新增配置字段
|
||||
val configName: String? = null, // 配置名(可选)
|
||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long
|
||||
|
||||
@@ -110,7 +110,7 @@ data class OrderTrackingRequest(
|
||||
val page: Int? = 1,
|
||||
val limit: Int? = 20,
|
||||
val marketId: String? = null,
|
||||
val side: String? = null,
|
||||
val marketTitle: String? = null, // 市场标题关键字筛选
|
||||
val status: String? = null,
|
||||
val sellOrderId: String? = null,
|
||||
val buyOrderId: String? = null
|
||||
@@ -123,7 +123,9 @@ data class MarketGroupedOrdersRequest(
|
||||
val copyTradingId: Long,
|
||||
val type: String, // buy, sell, matched
|
||||
val page: Int? = 1,
|
||||
val limit: Int? = 20
|
||||
val limit: Int? = 20,
|
||||
val marketId: String? = null,
|
||||
val marketTitle: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,8 @@ data class TemplateCreateRequest(
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -50,7 +51,8 @@ data class TemplateUpdateRequest(
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -84,7 +86,8 @@ data class TemplateCopyRequest(
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -119,6 +122,7 @@ data class TemplateDto(
|
||||
val maxSpread: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||
val pushFilteredOrders: Boolean, // 推送已过滤订单(默认关闭)
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
@@ -100,6 +100,9 @@ data class CopyTrading(
|
||||
@Column(name = "push_failed_orders", nullable = false)
|
||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||
|
||||
@Column(name = "push_filtered_orders", nullable = false)
|
||||
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||
|
||||
@Column(name = "max_market_end_date")
|
||||
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ data class CopyTradingTemplate(
|
||||
@Column(name = "max_price", precision = 20, scale = 8)
|
||||
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
|
||||
@Column(name = "push_filtered_orders", nullable = false)
|
||||
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
|
||||
@@ -948,6 +948,9 @@ class AccountService(
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 使用当前时间作为订单创建时间
|
||||
val orderTime = System.currentTimeMillis()
|
||||
|
||||
telegramNotificationService?.sendOrderSuccessNotification(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
@@ -963,7 +966,8 @@ class AccountService(
|
||||
apiSecret = try { cryptoUtils.decrypt(account.apiSecret!!) } catch (e: Exception) { null },
|
||||
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
|
||||
walletAddressForApi = account.walletAddress,
|
||||
locale = locale
|
||||
locale = locale,
|
||||
orderTime = orderTime // 使用订单创建时间
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送订单成功通知失败: ${e.message}", e)
|
||||
|
||||
@@ -7,11 +7,11 @@ import com.wrbug.polymarketbot.api.JsonRpcResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PositionResponse
|
||||
import com.wrbug.polymarketbot.api.ValueResponse
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
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 com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -26,8 +26,6 @@ import java.math.BigInteger
|
||||
*/
|
||||
@Service
|
||||
class BlockchainService(
|
||||
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
|
||||
private val dataApiBaseUrl: String,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val relayClientService: RelayClientService,
|
||||
private val rpcNodeService: RpcNodeService,
|
||||
@@ -61,10 +59,10 @@ class BlockchainService(
|
||||
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
|
||||
|
||||
private val dataApi: PolymarketDataApi by lazy {
|
||||
val baseUrl = if (dataApiBaseUrl.endsWith("/")) {
|
||||
dataApiBaseUrl.dropLast(1)
|
||||
val baseUrl = if (PolymarketConstants.DATA_API_BASE_URL.endsWith("/")) {
|
||||
PolymarketConstants.DATA_API_BASE_URL.dropLast(1)
|
||||
} else {
|
||||
dataApiBaseUrl
|
||||
PolymarketConstants.DATA_API_BASE_URL
|
||||
}
|
||||
val okHttpClient = createClient()
|
||||
.followRedirects(true)
|
||||
|
||||
+3
-5
@@ -3,12 +3,12 @@ package com.wrbug.polymarketbot.service.common
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
@@ -19,8 +19,6 @@ import retrofit2.converter.gson.GsonConverterFactory
|
||||
*/
|
||||
@Service
|
||||
class PolymarketApiKeyService(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
@@ -224,7 +222,7 @@ class PolymarketApiKeyService(
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
@@ -238,7 +236,7 @@ class PolymarketApiKeyService(
|
||||
val okHttpClient = createClient().build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
|
||||
+11
-3
@@ -71,12 +71,20 @@ class CopyTradingFilterService(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查是否需要获取订单簿
|
||||
// 只有在配置了需要订单簿的过滤条件时才获取
|
||||
// 3. 检查是否需要获取订单簿或需要执行仓位检查
|
||||
// 只有在配置了需要订单簿的过滤条件时才获取订单簿
|
||||
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
|
||||
|
||||
// 3.5. 如果不需要订单簿,则跳过订单簿相关的检查,但仍然需要检查仓位限制
|
||||
if (!needOrderbook) {
|
||||
// 不需要订单簿,直接通过
|
||||
// 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||
if (copyOrderAmount != null && marketId != null) {
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
||||
if (!positionCheck.isPassed) {
|
||||
return positionCheck
|
||||
}
|
||||
}
|
||||
// 通过所有检查
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
|
||||
+82
-11
@@ -10,6 +10,7 @@ import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.util.IllegalBigDecimal
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
@@ -86,7 +87,8 @@ class CopyTradingService(
|
||||
maxPositionCount = request.maxPositionCount,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = convertKeywordsToJson(request.keywords),
|
||||
maxMarketEndDate = request.maxMarketEndDate
|
||||
maxMarketEndDate = request.maxMarketEndDate,
|
||||
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders
|
||||
)
|
||||
} else {
|
||||
// 手动输入(所有字段必须提供)
|
||||
@@ -117,7 +119,8 @@ class CopyTradingService(
|
||||
maxPositionCount = request.maxPositionCount,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = convertKeywordsToJson(request.keywords),
|
||||
maxMarketEndDate = request.maxMarketEndDate
|
||||
maxMarketEndDate = request.maxMarketEndDate,
|
||||
pushFilteredOrders = request.pushFilteredOrders ?: false // 手动输入时使用请求中的值,默认为 false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,7 +153,8 @@ class CopyTradingService(
|
||||
keywords = config.keywords,
|
||||
configName = configName,
|
||||
pushFailedOrders = request.pushFailedOrders ?: false,
|
||||
maxMarketEndDate = config.maxMarketEndDate
|
||||
maxMarketEndDate = config.maxMarketEndDate,
|
||||
pushFilteredOrders = config.pushFilteredOrders
|
||||
)
|
||||
|
||||
val saved = copyTradingRepository.save(copyTrading)
|
||||
@@ -211,12 +215,67 @@ class CopyTradingService(
|
||||
websocketReconnectInterval = request.websocketReconnectInterval ?: copyTrading.websocketReconnectInterval,
|
||||
websocketMaxRetries = request.websocketMaxRetries ?: copyTrading.websocketMaxRetries,
|
||||
supportSell = request.supportSell ?: copyTrading.supportSell,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue,
|
||||
maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount,
|
||||
// 处理可选字段:空字符串表示要清空(设置为 null),null 表示不更新,转换失败保留旧值
|
||||
minOrderDepth = if (request.minOrderDepth != null) {
|
||||
if (request.minOrderDepth.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val converted = request.minOrderDepth.toSafeBigDecimal()
|
||||
if (converted == IllegalBigDecimal) copyTrading.minOrderDepth else converted
|
||||
}
|
||||
} else {
|
||||
copyTrading.minOrderDepth
|
||||
},
|
||||
maxSpread = if (request.maxSpread != null) {
|
||||
if (request.maxSpread.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val converted = request.maxSpread.toSafeBigDecimal()
|
||||
if (converted == IllegalBigDecimal) copyTrading.maxSpread else converted
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxSpread
|
||||
},
|
||||
minPrice = if (request.minPrice != null) {
|
||||
if (request.minPrice.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val converted = request.minPrice.toSafeBigDecimal()
|
||||
if (converted == IllegalBigDecimal) copyTrading.minPrice else converted
|
||||
}
|
||||
} else {
|
||||
copyTrading.minPrice
|
||||
},
|
||||
maxPrice = if (request.maxPrice != null) {
|
||||
if (request.maxPrice.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val converted = request.maxPrice.toSafeBigDecimal()
|
||||
if (converted == IllegalBigDecimal) copyTrading.maxPrice else converted
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxPrice
|
||||
},
|
||||
maxPositionValue = if (request.maxPositionValue != null) {
|
||||
if (request.maxPositionValue.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val converted = request.maxPositionValue.toSafeBigDecimal()
|
||||
if (converted == IllegalBigDecimal) copyTrading.maxPositionValue else converted
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxPositionValue
|
||||
},
|
||||
// 处理 maxPositionCount:-1 表示要清空(设置为 null),null 表示不更新
|
||||
maxPositionCount = if (request.maxPositionCount != null) {
|
||||
if (request.maxPositionCount == -1) {
|
||||
null
|
||||
} else {
|
||||
request.maxPositionCount
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxPositionCount
|
||||
},
|
||||
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
|
||||
keywords = if (request.keywords != null) {
|
||||
convertKeywordsToJson(request.keywords)
|
||||
@@ -227,7 +286,17 @@ class CopyTradingService(
|
||||
},
|
||||
configName = configName,
|
||||
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
|
||||
maxMarketEndDate = request.maxMarketEndDate ?: copyTrading.maxMarketEndDate,
|
||||
pushFilteredOrders = request.pushFilteredOrders ?: copyTrading.pushFilteredOrders,
|
||||
// 处理 maxMarketEndDate:-1 表示要清空(设置为 null),null 表示不更新
|
||||
maxMarketEndDate = if (request.maxMarketEndDate != null) {
|
||||
if (request.maxMarketEndDate == -1L) {
|
||||
null
|
||||
} else {
|
||||
request.maxMarketEndDate
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxMarketEndDate
|
||||
},
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -440,6 +509,7 @@ class CopyTradingService(
|
||||
keywords = convertJsonToKeywords(copyTrading.keywords),
|
||||
configName = copyTrading.configName,
|
||||
pushFailedOrders = copyTrading.pushFailedOrders,
|
||||
pushFilteredOrders = copyTrading.pushFilteredOrders,
|
||||
maxMarketEndDate = copyTrading.maxMarketEndDate,
|
||||
createdAt = copyTrading.createdAt,
|
||||
updatedAt = copyTrading.updatedAt
|
||||
@@ -502,6 +572,7 @@ class CopyTradingService(
|
||||
val maxPositionCount: Int?,
|
||||
val keywordFilterMode: String,
|
||||
val keywords: String?, // JSON 字符串
|
||||
val maxMarketEndDate: Long? // 市场截止时间限制(毫秒时间戳)
|
||||
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
|
||||
val pushFilteredOrders: Boolean // 推送已过滤订单(默认关闭)
|
||||
)
|
||||
}
|
||||
|
||||
+2
-3
@@ -9,8 +9,8 @@ import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
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
|
||||
@@ -28,8 +28,7 @@ class CopyTradingWebSocketService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
|
||||
|
||||
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
|
||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
||||
private val websocketUrl: String = PolymarketConstants.USER_WS_URL
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
|
||||
|
||||
+33
-27
@@ -1,5 +1,6 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.google.gson.JsonNull
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
@@ -23,12 +24,12 @@ class OnChainWsService(
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||
private val leaderRepository: LeaderRepository
|
||||
) {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
|
||||
|
||||
|
||||
// 存储需要监听的Leader:leaderId -> Leader
|
||||
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
||||
|
||||
|
||||
/**
|
||||
* 启动链上 WebSocket 监听
|
||||
* 通过统一服务订阅所有 Leader
|
||||
@@ -40,14 +41,14 @@ class OnChainWsService(
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 更新 Leader 列表
|
||||
monitoredLeaders.clear()
|
||||
leaders.forEach { leader ->
|
||||
addLeader(leader)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 添加Leader监听
|
||||
* 通过统一服务订阅该 Leader 的地址
|
||||
@@ -57,17 +58,17 @@ class OnChainWsService(
|
||||
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val leaderId = leader.id!!
|
||||
|
||||
|
||||
// 如果已经在监听列表中,不重复添加
|
||||
if (monitoredLeaders.containsKey(leaderId)) {
|
||||
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
monitoredLeaders[leaderId] = leader
|
||||
|
||||
|
||||
// 通过统一服务订阅
|
||||
val subscriptionId = "LEADER_$leaderId"
|
||||
unifiedOnChainWsService.subscribe(
|
||||
@@ -79,40 +80,45 @@ class OnChainWsService(
|
||||
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 处理 Leader 的交易
|
||||
*/
|
||||
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
private suspend fun handleLeaderTransaction(
|
||||
leaderId: Long,
|
||||
txHash: String,
|
||||
httpClient: OkHttpClient,
|
||||
rpcApi: EthereumRpcApi
|
||||
) {
|
||||
val leader = monitoredLeaders[leaderId] ?: return
|
||||
|
||||
|
||||
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
|
||||
|
||||
|
||||
try {
|
||||
// 获取交易 receipt
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionReceipt",
|
||||
params = listOf(txHash)
|
||||
)
|
||||
|
||||
|
||||
val receiptResponse = rpcApi.call(receiptRequest)
|
||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result is JsonNull) {
|
||||
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 使用 Gson 解析 receipt JSON
|
||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||
|
||||
|
||||
// 获取区块号和时间戳
|
||||
val blockNumber = receiptJson.get("blockNumber")?.asString
|
||||
val blockTimestamp = if (blockNumber != null) {
|
||||
@@ -120,7 +126,7 @@ class OnChainWsService(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
// 解析 receipt 中的 Transfer 日志
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: run {
|
||||
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
|
||||
@@ -128,7 +134,7 @@ class OnChainWsService(
|
||||
}
|
||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
||||
|
||||
|
||||
// 解析交易信息
|
||||
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||
txHash = txHash,
|
||||
@@ -138,7 +144,7 @@ class OnChainWsService(
|
||||
erc1155Transfers = erc1155Transfers,
|
||||
retrofitFactory = retrofitFactory
|
||||
)
|
||||
|
||||
|
||||
if (trade != null) {
|
||||
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
|
||||
// 调用 processTrade 处理交易
|
||||
@@ -154,21 +160,21 @@ class OnChainWsService(
|
||||
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 移除Leader监听
|
||||
* 取消该 Leader 的订阅
|
||||
*/
|
||||
fun removeLeader(leaderId: Long) {
|
||||
monitoredLeaders.remove(leaderId)
|
||||
|
||||
|
||||
// 通过统一服务取消订阅
|
||||
val subscriptionId = "LEADER_$leaderId"
|
||||
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
||||
|
||||
|
||||
logger.info("移除 Leader 监听: leaderId=$leaderId")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 停止监听
|
||||
*/
|
||||
@@ -180,7 +186,7 @@ class OnChainWsService(
|
||||
}
|
||||
monitoredLeaders.clear()
|
||||
}
|
||||
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
|
||||
+66
-3
@@ -7,11 +7,11 @@ import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -29,8 +29,7 @@ class PolymarketActivityWsService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
|
||||
|
||||
@Value("\${polymarket.websocket.activity.url:wss://ws-live-data.polymarket.com}")
|
||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
||||
private val websocketUrl: String = PolymarketConstants.ACTIVITY_WS_URL
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
@@ -44,6 +43,13 @@ class PolymarketActivityWsService(
|
||||
@Volatile
|
||||
private var isSubscribed = false
|
||||
|
||||
// 最后一次收到 activity 消息的时间(毫秒时间戳)
|
||||
@Volatile
|
||||
private var lastActivityTime: Long = 0
|
||||
|
||||
// Activity 消息超时检测任务
|
||||
private var activityTimeoutJob: Job? = null
|
||||
|
||||
/**
|
||||
* 启动监听
|
||||
*/
|
||||
@@ -184,6 +190,10 @@ class PolymarketActivityWsService(
|
||||
|
||||
client.sendMessage(subscribeMessage)
|
||||
isSubscribed = true
|
||||
// 重置最后一次收到 activity 消息的时间
|
||||
lastActivityTime = System.currentTimeMillis()
|
||||
// 启动 Activity 消息超时检测
|
||||
startActivityTimeoutCheck()
|
||||
logger.info("Activity WebSocket 订阅成功(全局交易流)")
|
||||
} catch (e: Exception) {
|
||||
logger.error("订阅 Activity WebSocket 失败", e)
|
||||
@@ -191,6 +201,54 @@ class PolymarketActivityWsService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Activity 消息超时检测
|
||||
* 每30秒检查一次,如果超过30秒没有收到activity消息,则重连
|
||||
*/
|
||||
private fun startActivityTimeoutCheck() {
|
||||
// 先停止之前的检测任务
|
||||
stopActivityTimeoutCheck()
|
||||
|
||||
activityTimeoutJob = scope.launch {
|
||||
while (isActive && isSubscribed) {
|
||||
delay(30000) // 每30秒检查一次
|
||||
|
||||
// 如果已经取消订阅,停止检测
|
||||
if (!isSubscribed) {
|
||||
break
|
||||
}
|
||||
|
||||
// 如果 lastActivityTime 为 0,说明还没有收到过消息,跳过本次检测
|
||||
if (lastActivityTime == 0L) {
|
||||
continue
|
||||
}
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val timeSinceLastActivity = currentTime - lastActivityTime
|
||||
|
||||
// 如果超过30秒没有收到activity消息,触发重连
|
||||
if (timeSinceLastActivity >= 30000) {
|
||||
logger.warn("超过30秒未收到 Activity 消息,触发重连。距离上次消息: ${timeSinceLastActivity}ms")
|
||||
// 关闭当前连接并重连
|
||||
wsClient?.closeConnection()
|
||||
wsClient = null
|
||||
isSubscribed = false
|
||||
// 重新连接
|
||||
connectAndSubscribe()
|
||||
break // 重连后会重新启动检测任务
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 Activity 消息超时检测
|
||||
*/
|
||||
private fun stopActivityTimeoutCheck() {
|
||||
activityTimeoutJob?.cancel()
|
||||
activityTimeoutJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息
|
||||
*/
|
||||
@@ -214,6 +272,9 @@ class PolymarketActivityWsService(
|
||||
return
|
||||
}
|
||||
|
||||
// 更新最后一次收到 activity 消息的时间(即使不是我们监听的 Leader 的交易)
|
||||
lastActivityTime = System.currentTimeMillis()
|
||||
|
||||
val payload = tradeMessage.payload
|
||||
|
||||
// 提取交易者地址
|
||||
@@ -383,10 +444,12 @@ class PolymarketActivityWsService(
|
||||
*/
|
||||
fun stop() {
|
||||
logger.info("停止 Activity WebSocket 监听")
|
||||
stopActivityTimeoutCheck()
|
||||
wsClient?.closeConnection()
|
||||
wsClient = null
|
||||
isSubscribed = false
|
||||
monitoredAddresses.clear()
|
||||
lastActivityTime = 0
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+14
-2
@@ -129,12 +129,20 @@ class UnifiedOnChainWsService(
|
||||
}
|
||||
addressConnections.clear()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
* @return Map<address, isConnected>
|
||||
*/
|
||||
fun getConnectionStatuses(): Map<String, Boolean> {
|
||||
return addressConnections.mapValues { (_, connection) -> connection.isConnected() }
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
|
||||
}
|
||||
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
@@ -211,6 +219,10 @@ class UnifiedOnChainWsService(
|
||||
return subscriptions.isEmpty()
|
||||
}
|
||||
|
||||
fun isConnected(): Boolean {
|
||||
return isConnected
|
||||
}
|
||||
|
||||
private suspend fun startConnectionLoop() {
|
||||
while (scope.isActive) {
|
||||
try {
|
||||
|
||||
+2
-2
@@ -18,6 +18,7 @@ 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 com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -41,8 +42,7 @@ class OrderPushService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
|
||||
|
||||
@Value("\${polymarket.rtds.ws-url}")
|
||||
private lateinit var polymarketWsUrl: String
|
||||
private val polymarketWsUrl: String = PolymarketConstants.RTDS_WS_URL
|
||||
|
||||
// 存储账户 ID 和对应的 WebSocket 连接
|
||||
private val accountConnections = ConcurrentHashMap<Long, PolymarketWebSocketClient>()
|
||||
|
||||
+22
-20
@@ -347,27 +347,29 @@ open class CopyOrderTrackingService(
|
||||
logger.error("保存被过滤订单失败: ${e.message}", e)
|
||||
}
|
||||
|
||||
// 发送 Telegram 通知
|
||||
val locale = try {
|
||||
org.springframework.context.i18n.LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
// 发送 Telegram 通知(仅在 pushFilteredOrders 为 true 时发送)
|
||||
if (copyTrading.pushFilteredOrders) {
|
||||
val locale = try {
|
||||
org.springframework.context.i18n.LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
telegramNotificationService?.sendOrderFilteredNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcome = trade.outcome,
|
||||
price = trade.price,
|
||||
size = trade.size,
|
||||
filterReason = filterResult.reason,
|
||||
filterType = filterType,
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
locale = locale
|
||||
)
|
||||
telegramNotificationService?.sendOrderFilteredNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcome = trade.outcome,
|
||||
price = trade.price,
|
||||
size = trade.size,
|
||||
filterReason = filterResult.reason,
|
||||
filterType = filterType,
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
locale = locale
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理被过滤订单通知失败: ${e.message}", e)
|
||||
}
|
||||
|
||||
+99
-229
@@ -31,8 +31,6 @@ class CopyTradingStatisticsService(
|
||||
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val accountService: AccountService,
|
||||
private val blockchainService: BlockchainService,
|
||||
private val marketService: com.wrbug.polymarketbot.service.common.MarketService
|
||||
) {
|
||||
|
||||
@@ -63,19 +61,12 @@ class CopyTradingStatisticsService(
|
||||
// 6. 计算统计信息
|
||||
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails)
|
||||
|
||||
// 7. 获取链上实际持仓(用于准确计算未实现盈亏,考虑手动卖出的情况)
|
||||
val actualPositions = getActualPositions(account)
|
||||
// 7. 不再计算未实现盈亏和持仓价值(优化性能)
|
||||
// 未实现盈亏计算需要查询链上持仓和市场价格,性能开销大
|
||||
val unrealizedPnl = "0"
|
||||
val positionValue = "0"
|
||||
|
||||
// 8. 获取当前市场价格(用于计算未实现盈亏)
|
||||
val currentPrice = getCurrentMarketPrice(buyOrders)
|
||||
|
||||
// 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity)
|
||||
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions)
|
||||
|
||||
// 10. 计算持仓价值(使用链上实际持仓和当前价格)
|
||||
val positionValue = calculatePositionValue(buyOrders, currentPrice, actualPositions)
|
||||
|
||||
// 11. 构建响应
|
||||
// 8. 构建响应(总盈亏 = 已实现盈亏)
|
||||
val response = CopyTradingStatisticsResponse(
|
||||
copyTradingId = copyTradingId,
|
||||
accountId = copyTrading.accountId,
|
||||
@@ -94,8 +85,8 @@ class CopyTradingStatisticsService(
|
||||
currentPositionValue = positionValue,
|
||||
totalRealizedPnl = statistics.totalRealizedPnl,
|
||||
totalUnrealizedPnl = unrealizedPnl,
|
||||
totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(),
|
||||
totalPnlPercent = calculatePnlPercent(statistics.totalBuyAmount, statistics.totalRealizedPnl, unrealizedPnl)
|
||||
totalPnl = statistics.totalRealizedPnl,
|
||||
totalPnlPercent = calculatePnlPercentOnlyRealized(statistics.totalBuyAmount, statistics.totalRealizedPnl)
|
||||
)
|
||||
|
||||
Result.success(response)
|
||||
@@ -143,12 +134,21 @@ class CopyTradingStatisticsService(
|
||||
private fun getBuyOrderList(request: OrderTrackingRequest): Pair<List<BuyOrderInfo>, Long> {
|
||||
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
|
||||
|
||||
// 批量获取市场信息(用于筛选)
|
||||
val allMarketIds = orders.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(allMarketIds)
|
||||
|
||||
// 筛选
|
||||
if (!request.marketId.isNullOrBlank()) {
|
||||
orders = orders.filter { it.marketId == request.marketId }
|
||||
// marketId 支持模糊匹配
|
||||
orders = orders.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
|
||||
}
|
||||
if (!request.side.isNullOrBlank()) {
|
||||
orders = orders.filter { it.side == request.side }
|
||||
if (!request.marketTitle.isNullOrBlank()) {
|
||||
// marketTitle 关键字筛选
|
||||
orders = orders.filter { order ->
|
||||
val market = markets[order.marketId]
|
||||
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
if (!request.status.isNullOrBlank()) {
|
||||
orders = orders.filter { it.status == request.status }
|
||||
@@ -166,10 +166,6 @@ class CopyTradingStatisticsService(
|
||||
val end = minOf(start + limit, orders.size)
|
||||
val pagedOrders = if (start < orders.size) orders.subList(start, end) else emptyList()
|
||||
|
||||
// 批量获取市场信息
|
||||
val marketIds = pagedOrders.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(marketIds)
|
||||
|
||||
// 转换为DTO
|
||||
val list = pagedOrders.map { order ->
|
||||
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
||||
@@ -202,12 +198,21 @@ class CopyTradingStatisticsService(
|
||||
private fun getSellOrderList(request: OrderTrackingRequest): Pair<List<SellOrderInfo>, Long> {
|
||||
var records = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
|
||||
|
||||
// 批量获取市场信息(用于筛选)
|
||||
val allMarketIds = records.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(allMarketIds)
|
||||
|
||||
// 筛选
|
||||
if (!request.marketId.isNullOrBlank()) {
|
||||
records = records.filter { it.marketId == request.marketId }
|
||||
// marketId 支持模糊匹配
|
||||
records = records.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
|
||||
}
|
||||
if (!request.side.isNullOrBlank()) {
|
||||
records = records.filter { it.side == request.side }
|
||||
if (!request.marketTitle.isNullOrBlank()) {
|
||||
// marketTitle 关键字筛选
|
||||
records = records.filter { record ->
|
||||
val market = markets[record.marketId]
|
||||
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
|
||||
val total = records.size.toLong()
|
||||
@@ -222,10 +227,6 @@ class CopyTradingStatisticsService(
|
||||
val end = minOf(start + limit, records.size)
|
||||
val pagedRecords = if (start < records.size) records.subList(start, end) else emptyList()
|
||||
|
||||
// 批量获取市场信息
|
||||
val marketIds = pagedRecords.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(marketIds)
|
||||
|
||||
// 转换为DTO
|
||||
val list = pagedRecords.map { record ->
|
||||
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
|
||||
@@ -256,6 +257,14 @@ class CopyTradingStatisticsService(
|
||||
private fun getMatchedOrderList(request: OrderTrackingRequest): Pair<List<MatchedOrderInfo>, Long> {
|
||||
val matchDetails = sellMatchDetailRepository.findByCopyTradingId(request.copyTradingId)
|
||||
|
||||
// 获取所有相关的卖出记录(用于筛选)
|
||||
val matchRecordIds = matchDetails.map { it.matchRecordId }.distinct()
|
||||
val matchRecords = matchRecordIds.mapNotNull { id ->
|
||||
sellMatchRecordRepository.findById(id).orElse(null)
|
||||
}
|
||||
val marketIds = matchRecords.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(marketIds)
|
||||
|
||||
// 筛选
|
||||
var filtered = matchDetails
|
||||
if (!request.sellOrderId.isNullOrBlank()) {
|
||||
@@ -269,6 +278,21 @@ class CopyTradingStatisticsService(
|
||||
if (!request.buyOrderId.isNullOrBlank()) {
|
||||
filtered = filtered.filter { it.buyOrderId == request.buyOrderId }
|
||||
}
|
||||
if (!request.marketId.isNullOrBlank()) {
|
||||
// marketId 支持模糊匹配
|
||||
filtered = filtered.filter { detail ->
|
||||
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
|
||||
matchRecord?.marketId?.contains(request.marketId!!, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
if (!request.marketTitle.isNullOrBlank()) {
|
||||
// marketTitle 关键字筛选
|
||||
filtered = filtered.filter { detail ->
|
||||
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
|
||||
val market = matchRecord?.let { markets[it.marketId] }
|
||||
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
|
||||
val total = filtered.size.toLong()
|
||||
|
||||
@@ -283,17 +307,17 @@ class CopyTradingStatisticsService(
|
||||
val pagedDetails = if (start < filtered.size) filtered.subList(start, end) else emptyList()
|
||||
|
||||
// 获取匹配记录以获取市场ID
|
||||
val matchRecordIds = pagedDetails.map { it.matchRecordId }.distinct()
|
||||
val matchRecords = matchRecordIds.mapNotNull { id ->
|
||||
val pagedMatchRecordIds = pagedDetails.map { it.matchRecordId }.distinct()
|
||||
val pagedMatchRecords = pagedMatchRecordIds.mapNotNull { id ->
|
||||
sellMatchRecordRepository.findById(id).orElse(null)
|
||||
}
|
||||
val marketIds = matchRecords.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(marketIds)
|
||||
val pagedMarketIds = pagedMatchRecords.map { it.marketId }.distinct()
|
||||
val pagedMarkets = marketService.getMarkets(pagedMarketIds)
|
||||
|
||||
// 转换为DTO
|
||||
val list = pagedDetails.map { detail ->
|
||||
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
|
||||
val market = matchRecord?.let { markets[it.marketId] }
|
||||
val matchRecord = pagedMatchRecords.find { it.id == detail.matchRecordId }
|
||||
val market = matchRecord?.let { pagedMarkets[it.marketId] }
|
||||
MatchedOrderInfo(
|
||||
sellOrderId = matchRecord?.sellOrderId ?: "",
|
||||
buyOrderId = detail.buyOrderId,
|
||||
@@ -358,200 +382,16 @@ class CopyTradingStatisticsService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前市场价格
|
||||
* 按 (marketId, outcomeIndex) 组合获取价格,支持多元市场
|
||||
* 计算盈亏百分比(仅基于已实现盈亏)
|
||||
*/
|
||||
private suspend fun getCurrentMarketPrice(buyOrders: List<CopyOrderTracking>): Map<String, String> {
|
||||
val prices = mutableMapOf<String, String>()
|
||||
|
||||
// 获取所有不同的 (marketId, outcomeIndex) 组合
|
||||
val marketOutcomePairs = buyOrders
|
||||
.filter { it.outcomeIndex != null }
|
||||
.map { Pair(it.marketId, it.outcomeIndex!!) }
|
||||
.distinct()
|
||||
|
||||
for ((marketId, outcomeIndex) in marketOutcomePairs) {
|
||||
try {
|
||||
// 传递 outcomeIndex 参数,确保获取对应 outcome 的价格
|
||||
val result = accountService.getMarketPrice(marketId, outcomeIndex)
|
||||
result.onSuccess { response ->
|
||||
// 使用当前价格
|
||||
val price = response.currentPrice
|
||||
if (price.isNotBlank() && price != "0") {
|
||||
// 使用 "marketId:outcomeIndex" 作为 key
|
||||
val key = "$marketId:$outcomeIndex"
|
||||
prices[key] = price
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场价格失败: marketId=$marketId, outcomeIndex=$outcomeIndex", e)
|
||||
}
|
||||
}
|
||||
|
||||
return prices
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取链上实际持仓
|
||||
* 按 (marketId, outcomeIndex) 组合返回实际持仓数量
|
||||
*/
|
||||
private suspend fun getActualPositions(account: Account?): Map<String, BigDecimal> {
|
||||
val positions = mutableMapOf<String, BigDecimal>()
|
||||
|
||||
if (account == null || account.proxyAddress.isBlank()) {
|
||||
return positions
|
||||
}
|
||||
|
||||
try {
|
||||
val positionsResult = blockchainService.getPositions(account.proxyAddress)
|
||||
if (positionsResult.isSuccess) {
|
||||
val positionList = positionsResult.getOrNull() ?: emptyList()
|
||||
for (pos in positionList) {
|
||||
// 只处理有 conditionId 和 outcomeIndex 的仓位
|
||||
if (pos.conditionId != null && pos.outcomeIndex != null && pos.size != null) {
|
||||
val key = "${pos.conditionId}:${pos.outcomeIndex}"
|
||||
val size = pos.size.toSafeBigDecimal()
|
||||
// 如果 size > 0,表示有持仓;如果 size < 0,表示做空(取绝对值)
|
||||
positions[key] = size.abs()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取链上持仓失败: accountId=${account.id}, error=${e.message}", e)
|
||||
}
|
||||
|
||||
return positions
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算未实现盈亏
|
||||
* 使用链上实际持仓数量,而不是 remainingQuantity(考虑手动卖出的情况)
|
||||
* 按市场聚合订单,计算加权平均买入价格,避免重复计算
|
||||
*/
|
||||
private fun calculateUnrealizedPnl(
|
||||
buyOrders: List<CopyOrderTracking>,
|
||||
currentPrices: Map<String, String>,
|
||||
actualPositions: Map<String, BigDecimal>
|
||||
): String {
|
||||
var totalUnrealizedPnl = BigDecimal.ZERO
|
||||
|
||||
// 按市场聚合订单,计算加权平均买入价格
|
||||
val marketAggregates = mutableMapOf<String, Pair<BigDecimal, BigDecimal>>() // key -> (总持仓, 总成本)
|
||||
|
||||
for (order in buyOrders) {
|
||||
// 如果没有 outcomeIndex,跳过(无法确定价格和持仓)
|
||||
if (order.outcomeIndex == null) {
|
||||
logger.warn("订单缺少 outcomeIndex,跳过未实现盈亏计算: orderId=${order.buyOrderId}, marketId=${order.marketId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用 "marketId:outcomeIndex" 作为 key
|
||||
val key = "${order.marketId}:${order.outcomeIndex}"
|
||||
|
||||
// 获取订单的持仓数量(使用 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
|
||||
|
||||
// 获取当前市场价格
|
||||
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
|
||||
?: continue // 如果没有当前价格,跳过
|
||||
|
||||
// 计算加权平均买入价格
|
||||
val avgBuyPrice = if (totalQty.gt(BigDecimal.ZERO)) {
|
||||
totalCost.div(totalQty)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用实际持仓数量和加权平均买入价格计算未实现盈亏
|
||||
val unrealizedPnl = currentPrice.subtract(avgBuyPrice).multi(actualQty)
|
||||
totalUnrealizedPnl = totalUnrealizedPnl.add(unrealizedPnl)
|
||||
}
|
||||
|
||||
return totalUnrealizedPnl.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算持仓价值
|
||||
* 使用链上实际持仓数量和当前市场价格计算
|
||||
* 按市场聚合,避免重复计算
|
||||
*/
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算盈亏百分比
|
||||
*/
|
||||
private fun calculatePnlPercent(
|
||||
private fun calculatePnlPercentOnlyRealized(
|
||||
totalBuyAmount: String,
|
||||
totalRealizedPnl: String,
|
||||
totalUnrealizedPnl: String
|
||||
totalRealizedPnl: String
|
||||
): String {
|
||||
val buyAmount = totalBuyAmount.toSafeBigDecimal()
|
||||
if (buyAmount.lte(BigDecimal.ZERO)) return "0"
|
||||
|
||||
val totalPnl = totalRealizedPnl.toSafeBigDecimal().add(totalUnrealizedPnl.toSafeBigDecimal())
|
||||
val percent = totalPnl.div(buyAmount).multi(100)
|
||||
val percent = totalRealizedPnl.toSafeBigDecimal().div(buyAmount).multi(100)
|
||||
|
||||
return percent.setScale(2, RoundingMode.HALF_UP).toString()
|
||||
}
|
||||
@@ -743,7 +583,23 @@ class CopyTradingStatisticsService(
|
||||
// 2. 获取所有买入订单
|
||||
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
|
||||
|
||||
// 3. 按市场ID分组
|
||||
// 3. 批量获取市场信息(用于筛选)
|
||||
val allMarketIds = orders.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(allMarketIds)
|
||||
|
||||
// 4. 筛选
|
||||
if (!request.marketId.isNullOrBlank()) {
|
||||
// marketId 支持模糊匹配
|
||||
orders = orders.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
|
||||
}
|
||||
if (!request.marketTitle.isNullOrBlank()) {
|
||||
// marketTitle 关键字筛选
|
||||
orders = orders.filter { order ->
|
||||
val market = markets[order.marketId]
|
||||
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
// 5. 按市场ID分组
|
||||
val groups = mutableMapOf<String, MutableList<CopyOrderTracking>>()
|
||||
orders.forEach { order ->
|
||||
val marketId = order.marketId
|
||||
@@ -755,7 +611,6 @@ class CopyTradingStatisticsService(
|
||||
|
||||
// 4. 转换为分组数据并计算统计信息
|
||||
val marketIds = groups.keys.toList()
|
||||
val markets = marketService.getMarkets(marketIds)
|
||||
|
||||
val list = marketIds.map { marketId ->
|
||||
val marketOrders = groups[marketId] ?: mutableListOf()
|
||||
@@ -852,9 +707,25 @@ class CopyTradingStatisticsService(
|
||||
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
|
||||
|
||||
// 2. 获取所有卖出记录
|
||||
val sellRecords = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
|
||||
var sellRecords = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
|
||||
|
||||
// 3. 按市场ID分组
|
||||
// 3. 批量获取市场信息(用于筛选)
|
||||
val allMarketIds = sellRecords.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(allMarketIds)
|
||||
|
||||
// 4. 筛选
|
||||
if (!request.marketId.isNullOrBlank()) {
|
||||
// marketId 支持模糊匹配
|
||||
sellRecords = sellRecords.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
|
||||
}
|
||||
if (!request.marketTitle.isNullOrBlank()) {
|
||||
// marketTitle 关键字筛选
|
||||
sellRecords = sellRecords.filter { record ->
|
||||
val market = markets[record.marketId]
|
||||
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
// 5. 按市场ID分组
|
||||
val groups = mutableMapOf<String, MutableList<SellMatchRecord>>()
|
||||
sellRecords.forEach { record ->
|
||||
val marketId = record.marketId
|
||||
@@ -866,7 +737,6 @@ class CopyTradingStatisticsService(
|
||||
|
||||
// 4. 转换为分组数据并计算统计信息
|
||||
val marketIds = groups.keys.toList()
|
||||
val markets = marketService.getMarkets(marketIds)
|
||||
|
||||
val list = marketIds.map { marketId ->
|
||||
val marketRecords = groups[marketId] ?: mutableListOf()
|
||||
|
||||
+83
-69
@@ -344,7 +344,8 @@ class OrderStatusUpdateService(
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
apiPassphrase = apiPassphrase,
|
||||
orderCreatedAt = record.createdAt
|
||||
)
|
||||
} else {
|
||||
logger.debug("自动生成的订单,跳过发送通知: orderId=${record.sellOrderId}")
|
||||
@@ -429,19 +430,7 @@ class OrderStatusUpdateService(
|
||||
totalRealizedPnl = totalRealizedPnl.add(updatedRealizedPnl)
|
||||
}
|
||||
|
||||
// 发送通知(使用实际价格)
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
|
||||
// 更新卖出记录
|
||||
// 先更新卖出记录,标记 priceUpdated = true(在发送通知之前更新)
|
||||
// 注意:SellMatchRecord 的字段都是 val,需要创建新对象
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
@@ -459,20 +448,23 @@ class OrderStatusUpdateService(
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
|
||||
logger.info("更新卖出订单价格成功并已发送通知: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
|
||||
} else {
|
||||
// 价格相同,但已经查询过,发送通知并标记为已处理
|
||||
logger.info("更新卖出订单价格成功: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
|
||||
|
||||
// 发送通知(使用实际价格)
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
record = updatedRecord,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
apiPassphrase = apiPassphrase,
|
||||
orderCreatedAt = record.createdAt
|
||||
)
|
||||
|
||||
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}")
|
||||
} else {
|
||||
// 价格相同,但已经查询过,先标记为已处理再发送通知
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
copyTradingId = record.copyTradingId,
|
||||
@@ -488,7 +480,22 @@ class OrderStatusUpdateService(
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
logger.debug("卖出订单价格无需更新但已发送通知: orderId=${record.sellOrderId}, price=$actualSellPrice")
|
||||
|
||||
logger.debug("卖出订单价格无需更新: orderId=${record.sellOrderId}, price=$actualSellPrice")
|
||||
|
||||
// 发送通知
|
||||
sendSellOrderNotification(
|
||||
record = updatedRecord,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase,
|
||||
orderCreatedAt = record.createdAt
|
||||
)
|
||||
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("更新卖出订单价格失败: orderId=${record.sellOrderId}, error=${e.message}", e)
|
||||
@@ -521,7 +528,7 @@ class OrderStatusUpdateService(
|
||||
// 验证 orderId 格式(必须以 0x 开头的 16 进制)
|
||||
if (!isValidOrderId(order.buyOrderId)) {
|
||||
logger.warn("买入订单ID格式无效,直接标记为已发送通知: orderId=${order.buyOrderId}")
|
||||
// 对于非 0x 开头的订单ID,直接标记为已发送,使用临时数据发送通知
|
||||
// 对于非 0x 开头的订单ID,先标记为已发送,再使用临时数据发送通知
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
@@ -542,7 +549,8 @@ class OrderStatusUpdateService(
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
sendBuyOrderNotification(updatedOrder, useTemporaryData = true)
|
||||
|
||||
sendBuyOrderNotification(updatedOrder, useTemporaryData = true, orderCreatedAt = order.createdAt)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -630,48 +638,50 @@ class OrderStatusUpdateService(
|
||||
// 更新订单数据(如果实际数据与临时数据不同)
|
||||
val needUpdate = actualPrice != order.price || actualSize != order.quantity
|
||||
|
||||
// 创建更新后的订单对象
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
accountId = order.accountId,
|
||||
leaderId = order.leaderId,
|
||||
marketId = order.marketId,
|
||||
side = order.side,
|
||||
outcomeIndex = order.outcomeIndex,
|
||||
buyOrderId = order.buyOrderId,
|
||||
leaderBuyTradeId = order.leaderBuyTradeId,
|
||||
quantity = actualSize, // 使用实际数量
|
||||
price = actualPrice, // 使用实际价格
|
||||
matchedQuantity = order.matchedQuantity,
|
||||
remainingQuantity = order.remainingQuantity,
|
||||
status = order.status,
|
||||
notificationSent = true, // 标记为已发送通知
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// 保存更新后的订单
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
|
||||
if (needUpdate) {
|
||||
logger.info("更新买入订单数据成功: orderId=${order.buyOrderId}, 原价格=${order.price}, 新价格=$actualPrice, 原数量=${order.quantity}, 新数量=$actualSize")
|
||||
} else {
|
||||
logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}")
|
||||
}
|
||||
|
||||
// 发送通知(使用实际数据)
|
||||
sendBuyOrderNotification(
|
||||
order = updatedOrder,
|
||||
actualPrice = actualPrice.toString(),
|
||||
actualSize = actualSize.toString(),
|
||||
actualOutcome = actualOutcome,
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
// 先保存更新后的订单,标记 notificationSent = true
|
||||
// 这样可以防止其他并发任务重复发送通知
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
accountId = order.accountId,
|
||||
leaderId = order.leaderId,
|
||||
marketId = order.marketId,
|
||||
side = order.side,
|
||||
outcomeIndex = order.outcomeIndex,
|
||||
buyOrderId = order.buyOrderId,
|
||||
leaderBuyTradeId = order.leaderBuyTradeId,
|
||||
quantity = actualSize, // 使用实际数量
|
||||
price = actualPrice, // 使用实际价格
|
||||
matchedQuantity = order.matchedQuantity,
|
||||
remainingQuantity = order.remainingQuantity,
|
||||
status = order.status,
|
||||
notificationSent = true, // 标记为已发送通知
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// 保存更新后的订单(在发送通知之前保存)
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
|
||||
if (needUpdate) {
|
||||
logger.info("更新买入订单数据成功: orderId=${order.buyOrderId}, 原价格=${order.price}, 新价格=$actualPrice, 原数量=${order.quantity}, 新数量=$actualSize")
|
||||
} else {
|
||||
logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}")
|
||||
}
|
||||
|
||||
// 发送通知(使用实际数据)
|
||||
sendBuyOrderNotification(
|
||||
order = updatedOrder,
|
||||
actualPrice = actualPrice.toString(),
|
||||
actualSize = actualSize.toString(),
|
||||
actualOutcome = actualOutcome,
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase,
|
||||
orderCreatedAt = order.createdAt
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("更新买入订单失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
// 继续处理下一条记录
|
||||
@@ -695,7 +705,8 @@ class OrderStatusUpdateService(
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
apiSecret: String? = null,
|
||||
apiPassphrase: String? = null
|
||||
apiPassphrase: String? = null,
|
||||
orderCreatedAt: Long? = null // 订单创建时间(毫秒时间戳)
|
||||
) {
|
||||
if (telegramNotificationService == null) {
|
||||
return
|
||||
@@ -762,7 +773,8 @@ class OrderStatusUpdateService(
|
||||
walletAddressForApi = finalAccount.walletAddress,
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
configName = configName,
|
||||
orderTime = orderCreatedAt // 使用订单创建时间
|
||||
)
|
||||
|
||||
logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}")
|
||||
@@ -785,7 +797,8 @@ class OrderStatusUpdateService(
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
apiSecret: String? = null,
|
||||
apiPassphrase: String? = null
|
||||
apiPassphrase: String? = null,
|
||||
orderCreatedAt: Long? = null // 订单创建时间(毫秒时间戳)
|
||||
) {
|
||||
if (telegramNotificationService == null) {
|
||||
return
|
||||
@@ -852,7 +865,8 @@ class OrderStatusUpdateService(
|
||||
walletAddressForApi = finalAccount.walletAddress,
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
configName = configName,
|
||||
orderTime = orderCreatedAt // 使用订单创建时间
|
||||
)
|
||||
|
||||
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}")
|
||||
|
||||
+6
-2
@@ -62,7 +62,8 @@ class CopyTradingTemplateService(
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
||||
pushFilteredOrders = request.pushFilteredOrders ?: false
|
||||
)
|
||||
|
||||
val saved = templateRepository.save(template)
|
||||
@@ -121,6 +122,7 @@ class CopyTradingTemplateService(
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -186,7 +188,8 @@ class CopyTradingTemplateService(
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice,
|
||||
pushFilteredOrders = request.pushFilteredOrders ?: sourceTemplate.pushFilteredOrders
|
||||
)
|
||||
|
||||
val saved = templateRepository.save(newTemplate)
|
||||
@@ -260,6 +263,7 @@ class CopyTradingTemplateService(
|
||||
maxSpread = template.maxSpread?.toPlainString(),
|
||||
minPrice = template.minPrice?.toPlainString(),
|
||||
maxPrice = template.maxPrice?.toPlainString(),
|
||||
pushFilteredOrders = template.pushFilteredOrders,
|
||||
createdAt = template.createdAt,
|
||||
updatedAt = template.updatedAt
|
||||
)
|
||||
|
||||
+154
-85
@@ -1,5 +1,6 @@
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckDto
|
||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
@@ -11,9 +12,9 @@ import org.slf4j.LoggerFactory
|
||||
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 com.wrbug.polymarketbot.service.copytrading.monitor.PolymarketActivityWsService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.UnifiedOnChainWsService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -22,16 +23,6 @@ import java.util.concurrent.TimeUnit
|
||||
*/
|
||||
@Service
|
||||
class ApiHealthCheckService(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String,
|
||||
@Value("\${polymarket.data-api.base-url}")
|
||||
private val dataApiBaseUrl: String,
|
||||
@Value("\${polymarket.gamma.base-url}")
|
||||
private val gammaBaseUrl: String,
|
||||
@Value("\${polymarket.rtds.ws-url}")
|
||||
private val polymarketWsUrl: String,
|
||||
@Value("\${polymarket.builder.relayer-url:}")
|
||||
private val builderRelayerUrl: String,
|
||||
private val rpcNodeService: RpcNodeService
|
||||
) : ApplicationContextAware {
|
||||
|
||||
@@ -52,17 +43,6 @@ class ApiHealthCheckService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取跟单 WebSocket 服务(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
private fun getCopyTradingWebSocketService(): CopyTradingWebSocketService? {
|
||||
return try {
|
||||
applicationContext?.getBean(CopyTradingWebSocketService::class.java)
|
||||
} catch (e: BeansException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 RelayClientService(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
@@ -74,6 +54,28 @@ class ApiHealthCheckService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 PolymarketActivityWsService(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
private fun getPolymarketActivityWsService(): PolymarketActivityWsService? {
|
||||
return try {
|
||||
applicationContext?.getBean(PolymarketActivityWsService::class.java)
|
||||
} catch (e: BeansException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 UnifiedOnChainWsService(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
private fun getUnifiedOnChainWsService(): UnifiedOnChainWsService? {
|
||||
return try {
|
||||
applicationContext?.getBean(UnifiedOnChainWsService::class.java)
|
||||
} catch (e: BeansException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java)
|
||||
|
||||
/**
|
||||
@@ -89,7 +91,9 @@ class ApiHealthCheckService(
|
||||
async { checkDataApi() },
|
||||
async { checkGammaApi() },
|
||||
async { checkPolygonRpc() },
|
||||
async { checkPolymarketWebSocket() },
|
||||
async { checkPolymarketRtdsWebSocket() },
|
||||
async { checkPolymarketActivityWebSocket() },
|
||||
async { checkUnifiedOnChainWebSocket() },
|
||||
async { checkBuilderRelayerApi() },
|
||||
async { checkGitHubApi() }
|
||||
)
|
||||
@@ -106,7 +110,7 @@ class ApiHealthCheckService(
|
||||
* 检查 Polymarket CLOB API
|
||||
*/
|
||||
private suspend fun checkClobApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
val url = "$clobBaseUrl/"
|
||||
val url = "${PolymarketConstants.CLOB_BASE_URL}/"
|
||||
checkApi("Polymarket CLOB API", url)
|
||||
}
|
||||
|
||||
@@ -114,7 +118,7 @@ class ApiHealthCheckService(
|
||||
* 检查 Polymarket Data API
|
||||
*/
|
||||
private suspend fun checkDataApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
val url = "$dataApiBaseUrl/"
|
||||
val url = "${PolymarketConstants.DATA_API_BASE_URL}/"
|
||||
checkApi("Polymarket Data API", url)
|
||||
}
|
||||
|
||||
@@ -131,7 +135,7 @@ class ApiHealthCheckService(
|
||||
.build()
|
||||
|
||||
// 使用 /markets 接口检查(不传参数,返回空列表或少量市场数据)
|
||||
val url = "$gammaBaseUrl/markets"
|
||||
val url = "${PolymarketConstants.GAMMA_BASE_URL}/markets"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
@@ -176,7 +180,7 @@ class ApiHealthCheckService(
|
||||
logger.warn("检查 Polymarket Gamma API 失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket Gamma API",
|
||||
url = "$gammaBaseUrl/markets",
|
||||
url = "${PolymarketConstants.GAMMA_BASE_URL}/markets",
|
||||
status = "error",
|
||||
message = e.message ?: "连接失败"
|
||||
)
|
||||
@@ -194,69 +198,143 @@ class ApiHealthCheckService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Polymarket WebSocket 连接状态
|
||||
* 不显示延时,只显示连接状态
|
||||
* 检查 Polymarket RTDS WebSocket 连接状态
|
||||
* 用于订单推送服务
|
||||
*/
|
||||
private suspend fun checkPolymarketWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
|
||||
private suspend fun checkPolymarketRtdsWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
// 检查订单推送服务的连接状态
|
||||
val orderPushService = getOrderPushService()
|
||||
val orderPushStatuses = orderPushService?.getConnectionStatuses() ?: emptyMap()
|
||||
val orderPushConnected = orderPushStatuses.values.any { it }
|
||||
val orderPushTotal = orderPushStatuses.size
|
||||
val orderPushConnectedCount = orderPushStatuses.values.count { it }
|
||||
val statuses = orderPushService?.getConnectionStatuses() ?: emptyMap()
|
||||
val total = statuses.size
|
||||
val connected = statuses.values.count { it }
|
||||
|
||||
// 检查跟单 WebSocket 服务的连接状态
|
||||
val copyTradingWebSocketService = getCopyTradingWebSocketService()
|
||||
val copyTradingStatuses = copyTradingWebSocketService?.getConnectionStatuses() ?: emptyMap()
|
||||
val copyTradingConnected = copyTradingStatuses.values.any { it }
|
||||
val copyTradingTotal = copyTradingStatuses.size
|
||||
val copyTradingConnectedCount = copyTradingStatuses.values.count { it }
|
||||
|
||||
// 计算总体状态
|
||||
val totalConnections = orderPushTotal + copyTradingTotal
|
||||
val connectedConnections = orderPushConnectedCount + copyTradingConnectedCount
|
||||
|
||||
val url = polymarketWsUrl
|
||||
val hasAnyConnection = orderPushConnected || copyTradingConnected
|
||||
|
||||
if (totalConnections == 0) {
|
||||
// 没有配置任何 WebSocket 连接
|
||||
if (total == 0) {
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket WebSocket",
|
||||
url = url,
|
||||
name = "Polymarket RTDS WebSocket",
|
||||
url = PolymarketConstants.RTDS_WS_URL,
|
||||
status = "skipped",
|
||||
message = "未配置 WebSocket 连接"
|
||||
message = "未配置账户连接"
|
||||
)
|
||||
} else if (hasAnyConnection) {
|
||||
// 至少有一个连接是活跃的
|
||||
val message = if (connectedConnections == totalConnections) {
|
||||
"所有连接正常 ($connectedConnections/$totalConnections)"
|
||||
} else if (connected > 0) {
|
||||
val message = if (connected == total) {
|
||||
"所有账户连接正常 ($connected/$total)"
|
||||
} else {
|
||||
"部分连接正常 ($connectedConnections/$totalConnections)"
|
||||
"部分账户连接正常 ($connected/$total)"
|
||||
}
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket WebSocket",
|
||||
url = url,
|
||||
name = "Polymarket RTDS WebSocket",
|
||||
url = PolymarketConstants.RTDS_WS_URL,
|
||||
status = "success",
|
||||
message = message
|
||||
// 不设置 responseTime,WebSocket 不显示延时
|
||||
)
|
||||
} else {
|
||||
// 所有连接都断开
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket WebSocket",
|
||||
url = url,
|
||||
name = "Polymarket RTDS WebSocket",
|
||||
url = PolymarketConstants.RTDS_WS_URL,
|
||||
status = "error",
|
||||
message = "所有连接断开 ($connectedConnections/$totalConnections)"
|
||||
// 不设置 responseTime,WebSocket 不显示延时
|
||||
message = "所有账户连接断开 (0/$total)"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查 Polymarket WebSocket 状态失败", e)
|
||||
logger.warn("检查 Polymarket RTDS WebSocket 状态失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket WebSocket",
|
||||
url = polymarketWsUrl,
|
||||
name = "Polymarket RTDS WebSocket",
|
||||
url = PolymarketConstants.RTDS_WS_URL,
|
||||
status = "error",
|
||||
message = "检查失败:${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Polymarket Activity WebSocket 连接状态
|
||||
* 用于 Activity 全局交易流监听
|
||||
*/
|
||||
private suspend fun checkPolymarketActivityWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val activityWsService = getPolymarketActivityWsService()
|
||||
val isConnected = activityWsService?.isConnected() ?: false
|
||||
|
||||
if (isConnected) {
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket Activity WebSocket",
|
||||
url = PolymarketConstants.ACTIVITY_WS_URL,
|
||||
status = "success",
|
||||
message = "连接正常"
|
||||
)
|
||||
} else {
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket Activity WebSocket",
|
||||
url = PolymarketConstants.ACTIVITY_WS_URL,
|
||||
status = "error",
|
||||
message = "连接断开"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查 Polymarket Activity WebSocket 状态失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket Activity WebSocket",
|
||||
url = PolymarketConstants.ACTIVITY_WS_URL,
|
||||
status = "error",
|
||||
message = "检查失败:${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查统一链上 WebSocket 连接状态
|
||||
* 用于监听链上事件
|
||||
*/
|
||||
private suspend fun checkUnifiedOnChainWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val unifiedOnChainWsService = getUnifiedOnChainWsService()
|
||||
|
||||
if (unifiedOnChainWsService == null) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "链上 WebSocket",
|
||||
url = rpcNodeService.getWsUrl(),
|
||||
status = "error",
|
||||
message = "服务未初始化"
|
||||
)
|
||||
}
|
||||
|
||||
// 检查连接状态
|
||||
val statuses = unifiedOnChainWsService.getConnectionStatuses()
|
||||
val total = statuses.size
|
||||
val connected = statuses.values.count { it }
|
||||
|
||||
if (total == 0) {
|
||||
ApiHealthCheckDto(
|
||||
name = "链上 WebSocket",
|
||||
url = rpcNodeService.getWsUrl(),
|
||||
status = "skipped",
|
||||
message = "未配置地址监听"
|
||||
)
|
||||
} else if (connected > 0) {
|
||||
val message = if (connected == total) {
|
||||
"所有地址连接正常 ($connected/$total)"
|
||||
} else {
|
||||
"部分地址连接正常 ($connected/$total)"
|
||||
}
|
||||
ApiHealthCheckDto(
|
||||
name = "链上 WebSocket",
|
||||
url = rpcNodeService.getWsUrl(),
|
||||
status = "success",
|
||||
message = message
|
||||
)
|
||||
} else {
|
||||
ApiHealthCheckDto(
|
||||
name = "链上 WebSocket",
|
||||
url = rpcNodeService.getWsUrl(),
|
||||
status = "error",
|
||||
message = "所有地址连接断开 (0/$total)"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查链上 WebSocket 状态失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "链上 WebSocket",
|
||||
url = rpcNodeService.getWsUrl(),
|
||||
status = "error",
|
||||
message = "检查失败:${e.message}"
|
||||
)
|
||||
@@ -390,19 +468,10 @@ class ApiHealthCheckService(
|
||||
private suspend fun checkBuilderRelayerApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
val relayClientService = getRelayClientService()
|
||||
|
||||
if (builderRelayerUrl.isBlank()) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "Builder Relayer API",
|
||||
url = "未配置",
|
||||
status = "skipped",
|
||||
message = "未配置 Builder Relayer URL"
|
||||
)
|
||||
}
|
||||
|
||||
if (relayClientService == null) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "Builder Relayer API",
|
||||
url = builderRelayerUrl,
|
||||
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
status = "error",
|
||||
message = "服务未初始化"
|
||||
)
|
||||
@@ -411,7 +480,7 @@ class ApiHealthCheckService(
|
||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "Builder Relayer API",
|
||||
url = builderRelayerUrl,
|
||||
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
status = "skipped",
|
||||
message = "Builder API Key 未配置"
|
||||
)
|
||||
@@ -423,7 +492,7 @@ class ApiHealthCheckService(
|
||||
onSuccess = { responseTime ->
|
||||
ApiHealthCheckDto(
|
||||
name = "Builder Relayer API",
|
||||
url = builderRelayerUrl,
|
||||
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
status = "success",
|
||||
message = "连接成功",
|
||||
responseTime = responseTime
|
||||
@@ -432,7 +501,7 @@ class ApiHealthCheckService(
|
||||
onFailure = { e ->
|
||||
ApiHealthCheckDto(
|
||||
name = "Builder Relayer API",
|
||||
url = builderRelayerUrl,
|
||||
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
status = "error",
|
||||
message = e.message ?: "连接失败"
|
||||
)
|
||||
@@ -442,7 +511,7 @@ class ApiHealthCheckService(
|
||||
logger.warn("检查 Builder Relayer API 失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "Builder Relayer API",
|
||||
url = builderRelayerUrl,
|
||||
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
status = "error",
|
||||
message = e.message ?: "连接失败"
|
||||
)
|
||||
|
||||
+53
-48
@@ -3,11 +3,11 @@ package com.wrbug.polymarketbot.service.system
|
||||
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigInteger
|
||||
|
||||
@@ -24,8 +24,6 @@ import java.math.BigInteger
|
||||
*/
|
||||
@Service
|
||||
class RelayClientService(
|
||||
@Value("\${polymarket.builder.relayer-url:}")
|
||||
private val builderRelayerUrl: String,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val systemConfigService: SystemConfigService,
|
||||
private val rpcNodeService: RpcNodeService
|
||||
@@ -54,10 +52,10 @@ class RelayClientService(
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
|
||||
|
||||
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
return retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = builderRelayerUrl,
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey!!,
|
||||
secret = builderSecret!!,
|
||||
passphrase = builderPassphrase!!
|
||||
@@ -74,19 +72,19 @@ class RelayClientService(
|
||||
builderSecret: String?,
|
||||
builderPassphrase: String?
|
||||
): Boolean {
|
||||
return builderRelayerUrl.isNotBlank() &&
|
||||
return PolymarketConstants.BUILDER_RELAYER_URL.isNotBlank() &&
|
||||
builderApiKey != null && builderApiKey.isNotBlank() &&
|
||||
builderSecret != null && builderSecret.isNotBlank() &&
|
||||
builderPassphrase != null && builderPassphrase.isNotBlank()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 检查 Builder API Key 是否已配置
|
||||
*/
|
||||
fun isBuilderApiKeyConfigured(): Boolean {
|
||||
return systemConfigService.isBuilderApiKeyConfigured()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 检查 Builder Relayer API 健康状态(用于 API 健康检查)
|
||||
*/
|
||||
@@ -95,24 +93,24 @@ class RelayClientService(
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
|
||||
|
||||
if (builderApiKey == null || builderSecret == null || builderPassphrase == null) {
|
||||
return Result.failure(IllegalStateException("Builder API Key 未配置"))
|
||||
}
|
||||
|
||||
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = builderRelayerUrl,
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey,
|
||||
secret = builderSecret,
|
||||
passphrase = builderPassphrase
|
||||
)
|
||||
|
||||
|
||||
// 使用一个测试地址来检查 API 是否可用(使用一个已知的地址,如零地址)
|
||||
val testAddress = "0x0000000000000000000000000000000000000000"
|
||||
val startTime = System.currentTimeMillis()
|
||||
val response = relayerApi.getDeployed(testAddress)
|
||||
val responseTime = System.currentTimeMillis() - startTime
|
||||
|
||||
|
||||
if (response.isSuccessful) {
|
||||
Result.success(responseTime)
|
||||
} else {
|
||||
@@ -228,11 +226,18 @@ class RelayClientService(
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
|
||||
|
||||
// 优先使用 Builder Relayer(Gasless)
|
||||
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
logger.info("使用 Builder Relayer 执行 Gasless 交易")
|
||||
return executeViaBuilderRelayer(privateKey, proxyAddress, safeTx, builderApiKey!!, builderSecret!!, builderPassphrase!!)
|
||||
return executeViaBuilderRelayer(
|
||||
privateKey,
|
||||
proxyAddress,
|
||||
safeTx,
|
||||
builderApiKey!!,
|
||||
builderSecret!!,
|
||||
builderPassphrase!!
|
||||
)
|
||||
}
|
||||
|
||||
// 回退到手动发送交易(需要用户支付 gas)
|
||||
@@ -256,9 +261,8 @@ class RelayClientService(
|
||||
builderSecret: String,
|
||||
builderPassphrase: String
|
||||
): Result<String> {
|
||||
val rpcApi = polygonRpcApi
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = builderRelayerUrl,
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey,
|
||||
secret = builderSecret,
|
||||
passphrase = builderPassphrase
|
||||
@@ -320,19 +324,19 @@ class RelayClientService(
|
||||
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
|
||||
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
||||
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
|
||||
|
||||
|
||||
// 对带前缀的消息进行 keccak256 哈希
|
||||
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
||||
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||
keccak256.doFinal(hashWithPrefix, 0)
|
||||
|
||||
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||
|
||||
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||
val packedSignature = splitAndPackSig(safeSignature)
|
||||
|
||||
|
||||
// 调试日志(地址已遮蔽)
|
||||
logger.debug("=== Builder Relayer 签名调试 ===")
|
||||
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
|
||||
@@ -358,7 +362,7 @@ class RelayClientService(
|
||||
),
|
||||
metadata = "Redeem positions via Builder Relayer"
|
||||
)
|
||||
|
||||
|
||||
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
|
||||
|
||||
// 调用 Builder Relayer API(认证头通过拦截器添加)
|
||||
@@ -372,7 +376,7 @@ class RelayClientService(
|
||||
|
||||
val relayerResponse = response.body()!!
|
||||
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
|
||||
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
|
||||
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
|
||||
|
||||
logger.info("Builder Relayer 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
|
||||
return Result.success(txHash)
|
||||
@@ -381,14 +385,14 @@ class RelayClientService(
|
||||
/**
|
||||
* 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||
* 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
|
||||
*
|
||||
*
|
||||
* TypeScript 实现流程:
|
||||
* 1. 从签名字符串中提取 v(最后 2 个字符)
|
||||
* 2. 调整 v 值(0,1 -> +31; 27,28 -> +4)
|
||||
* 3. 修改签名字符串(替换最后 2 个字符)
|
||||
* 4. 从修改后的签名字符串中提取 r, s, v(作为十进制字符串)
|
||||
* 5. 使用 encodePacked 打包:uint256(BigInt(r)) + uint256(BigInt(s)) + uint8(parseInt(v))
|
||||
*
|
||||
*
|
||||
* 关键:encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
|
||||
*/
|
||||
private fun splitAndPackSig(signature: org.web3j.crypto.Sign.SignatureData): String {
|
||||
@@ -403,37 +407,37 @@ class RelayClientService(
|
||||
}
|
||||
val originalVHex = String.format("%02x", originalV)
|
||||
val sigString = "0x$rHex$sHex$originalVHex" // 130 个十六进制字符(65 字节)
|
||||
|
||||
|
||||
// 2. 从签名字符串中提取 v(最后 2 个字符,作为十六进制)
|
||||
val sigV = sigString.substring(sigString.length - 2).toInt(16)
|
||||
|
||||
|
||||
// 3. 调整 v 值(参考 TypeScript 实现)
|
||||
val adjustedV = when (sigV) {
|
||||
0, 1 -> sigV + 31
|
||||
27, 28 -> sigV + 4
|
||||
else -> throw IllegalArgumentException("Invalid signature v value: $sigV")
|
||||
}
|
||||
|
||||
|
||||
// 4. 修改签名字符串(替换最后 2 个字符)
|
||||
val modifiedSigString = sigString.substring(0, sigString.length - 2) + String.format("%02x", adjustedV)
|
||||
|
||||
|
||||
// 5. 从修改后的签名字符串中提取 r, s, v(作为十六进制字符串)
|
||||
// modifiedSigString 格式:0x + r(64) + s(64) + v(2) = 132 个字符
|
||||
val rHexStr = modifiedSigString.substring(2, 66) // 64 个字符(十六进制)
|
||||
val sHexStr = modifiedSigString.substring(66, 130) // 64 个字符(十六进制)
|
||||
val vHexStr = modifiedSigString.substring(130, 132) // 2 个字符(十六进制)
|
||||
|
||||
|
||||
// 6. 转换为 BigInteger 和 Int(模拟 TypeScript 的 BigInt 和 parseInt)
|
||||
val rBigInt = BigInteger(rHexStr, 16)
|
||||
val sBigInt = BigInteger(sHexStr, 16)
|
||||
val vInt = vHexStr.toInt(16)
|
||||
|
||||
|
||||
// 7. 使用 encodePacked 打包:uint256(r) + uint256(s) + uint8(v)
|
||||
// encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
|
||||
val rEncoded = EthereumUtils.encodeUint256(rBigInt) // 64 个十六进制字符
|
||||
val sEncoded = EthereumUtils.encodeUint256(sBigInt) // 64 个十六进制字符
|
||||
val vEncoded = String.format("%02x", vInt) // 2 个十六进制字符
|
||||
|
||||
|
||||
return "0x$rEncoded$sEncoded$vEncoded"
|
||||
}
|
||||
|
||||
@@ -504,13 +508,13 @@ class RelayClientService(
|
||||
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
|
||||
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
||||
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
|
||||
|
||||
|
||||
// 对带前缀的消息进行 keccak256 哈希
|
||||
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
||||
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||
keccak256.doFinal(hashWithPrefix, 0)
|
||||
|
||||
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||
|
||||
@@ -572,7 +576,8 @@ class RelayClientService(
|
||||
redeemCallData: String,
|
||||
safeSignatureHex: String
|
||||
): String {
|
||||
val execFunctionSelector = EthereumUtils.getFunctionSelector("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)")
|
||||
val execFunctionSelector =
|
||||
EthereumUtils.getFunctionSelector("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)")
|
||||
|
||||
val encodedTo = EthereumUtils.encodeAddress(safeTx.to)
|
||||
val encodedValue = EthereumUtils.encodeUint256(BigInteger.ZERO)
|
||||
@@ -600,20 +605,20 @@ class RelayClientService(
|
||||
val encodedSignatures = safeSignatureHex
|
||||
|
||||
return "0x" + execFunctionSelector.removePrefix("0x") +
|
||||
encodedTo +
|
||||
encodedValue +
|
||||
encodedDataOffset +
|
||||
encodedDataLength +
|
||||
encodedData +
|
||||
encodedOperation +
|
||||
encodedSafeTxGas +
|
||||
encodedBaseGas +
|
||||
encodedGasPrice +
|
||||
encodedGasToken +
|
||||
encodedRefundReceiver +
|
||||
encodedSignaturesOffset +
|
||||
encodedSignaturesLength +
|
||||
encodedSignatures
|
||||
encodedTo +
|
||||
encodedValue +
|
||||
encodedDataOffset +
|
||||
encodedDataLength +
|
||||
encodedData +
|
||||
encodedOperation +
|
||||
encodedSafeTxGas +
|
||||
encodedBaseGas +
|
||||
encodedGasPrice +
|
||||
encodedGasToken +
|
||||
encodedRefundReceiver +
|
||||
encodedSignaturesOffset +
|
||||
encodedSignaturesLength +
|
||||
encodedSignatures
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+13
-4
@@ -77,6 +77,7 @@ class TelegramNotificationService(
|
||||
* @param apiPassphrase API Passphrase(可选,用于查询订单详情)
|
||||
* @param walletAddressForApi 钱包地址(可选,用于查询订单详情)
|
||||
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||
* @param orderTime 订单时间(可选,如果提供则使用订单创建时间,否则使用当前通知时间)
|
||||
*/
|
||||
suspend fun sendOrderSuccessNotification(
|
||||
orderId: String?,
|
||||
@@ -96,7 +97,8 @@ class TelegramNotificationService(
|
||||
walletAddressForApi: String? = null,
|
||||
locale: java.util.Locale? = null,
|
||||
leaderName: String? = null, // Leader 名称(备注)
|
||||
configName: String? = null // 跟单配置名
|
||||
configName: String? = null, // 跟单配置名
|
||||
orderTime: Long? = null // 订单创建时间(毫秒时间戳),用于通知中的时间显示
|
||||
) {
|
||||
// 1. 如果提供了 orderId,检查是否已发送过通知(去重)
|
||||
if (orderId != null) {
|
||||
@@ -189,7 +191,8 @@ class TelegramNotificationService(
|
||||
walletAddress = walletAddress,
|
||||
locale = currentLocale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
configName = configName,
|
||||
orderTime = orderTime
|
||||
)
|
||||
sendMessage(message)
|
||||
}
|
||||
@@ -699,7 +702,8 @@ class TelegramNotificationService(
|
||||
walletAddress: String?,
|
||||
locale: java.util.Locale,
|
||||
leaderName: String? = null, // Leader 名称(备注)
|
||||
configName: String? = null // 跟单配置名
|
||||
configName: String? = null, // 跟单配置名
|
||||
orderTime: Long? = null // 订单创建时间(毫秒时间戳)
|
||||
): String {
|
||||
|
||||
// 获取多语言文本
|
||||
@@ -749,7 +753,12 @@ class TelegramNotificationService(
|
||||
""
|
||||
}
|
||||
|
||||
val time = DateUtils.formatDateTime()
|
||||
// 使用订单时间(如果提供),否则使用当前通知时间
|
||||
val time = if (orderTime != null) {
|
||||
DateUtils.formatDateTime(orderTime)
|
||||
} else {
|
||||
DateUtils.formatDateTime()
|
||||
}
|
||||
|
||||
// 转义 HTML 特殊字符
|
||||
val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">")
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.api.GitHubApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
@@ -18,7 +19,6 @@ import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
@@ -34,10 +34,6 @@ import jakarta.annotation.PreDestroy
|
||||
*/
|
||||
@Component
|
||||
class RetrofitFactory(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String,
|
||||
@Value("\${polymarket.gamma.base-url}")
|
||||
private val gammaBaseUrl: String,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
@@ -58,10 +54,10 @@ class RetrofitFactory(
|
||||
|
||||
// 缓存 Gamma API 客户端(单例)
|
||||
private val gammaApi: PolymarketGammaApi by lazy {
|
||||
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
|
||||
gammaBaseUrl.dropLast(1)
|
||||
val baseUrl = if (PolymarketConstants.GAMMA_BASE_URL.endsWith("/")) {
|
||||
PolymarketConstants.GAMMA_BASE_URL.dropLast(1)
|
||||
} else {
|
||||
gammaBaseUrl
|
||||
PolymarketConstants.GAMMA_BASE_URL
|
||||
}
|
||||
|
||||
Retrofit.Builder()
|
||||
@@ -74,7 +70,7 @@ class RetrofitFactory(
|
||||
|
||||
// 缓存 Data API 客户端(单例)
|
||||
private val dataApi: PolymarketDataApi by lazy {
|
||||
val baseUrl = "https://data-api.polymarket.com"
|
||||
val baseUrl = PolymarketConstants.DATA_API_BASE_URL
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl("$baseUrl/")
|
||||
@@ -113,7 +109,7 @@ class RetrofitFactory(
|
||||
// 缓存不带认证的 CLOB API 客户端(单例)
|
||||
private val clobApiWithoutAuth: PolymarketClobApi by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(sharedOkHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
@@ -158,7 +154,7 @@ class RetrofitFactory(
|
||||
.build()
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
package com.wrbug.polymarketbot.websocket
|
||||
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.socket.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -15,8 +15,7 @@ class PolymarketWebSocketHandler : WebSocketHandler {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
|
||||
|
||||
@Value("\${polymarket.rtds.ws-url}")
|
||||
private lateinit var polymarketWsUrl: String
|
||||
private val polymarketWsUrl: String = PolymarketConstants.RTDS_WS_URL
|
||||
|
||||
// 存储客户端会话和对应的 Polymarket 连接的映射
|
||||
private val clientSessions = ConcurrentHashMap<String, WebSocketSession>()
|
||||
|
||||
@@ -35,18 +35,14 @@ logging.level.com.wrbug.polymarketbot=${LOG_LEVEL_APP:INFO}
|
||||
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
|
||||
|
||||
# Polymarket API 配置
|
||||
polymarket.clob.base-url=https://clob.polymarket.com
|
||||
polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
|
||||
polymarket.websocket.url=wss://ws-live-data.polymarket.com
|
||||
polymarket.websocket.activity.url=${POLYMARKET_WEBSOCKET_ACTIVITY_URL:wss://ws-live-data.polymarket.com}
|
||||
polymarket.data-api.base-url=https://data-api.polymarket.com
|
||||
polymarket.gamma.base-url=https://gamma-api.polymarket.com
|
||||
# 注意:Polymarket API URL 现在使用代码常量(PolymarketConstants),不再从配置文件读取
|
||||
# 如需修改,请修改 com.wrbug.polymarketbot.constants.PolymarketConstants 类
|
||||
|
||||
# Builder Relayer 配置(用于 Gasless 交易)
|
||||
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
|
||||
# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中
|
||||
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
|
||||
polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/}
|
||||
# 注意:Builder Relayer URL 现在使用代码常量(PolymarketConstants.BUILDER_RELAYER_URL),不再从配置文件读取
|
||||
|
||||
# 跟单轮询配置
|
||||
# 轮询间隔(毫秒),默认2秒
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
-- ============================================
|
||||
-- V24: 添加推送已过滤订单字段到模板表和跟单配置表
|
||||
-- 用于配置是否推送被过滤的订单通知,默认关闭
|
||||
-- ============================================
|
||||
|
||||
-- 添加推送已过滤订单字段到模板表
|
||||
ALTER TABLE copy_trading_templates
|
||||
ADD COLUMN push_filtered_orders BOOLEAN NOT NULL DEFAULT FALSE COMMENT '推送已过滤订单(默认关闭)';
|
||||
|
||||
-- 添加推送已过滤订单字段到跟单配置表
|
||||
ALTER TABLE copy_trading
|
||||
ADD COLUMN push_filtered_orders BOOLEAN NOT NULL DEFAULT FALSE COMMENT '推送已过滤订单(默认关闭)';
|
||||
|
||||
@@ -19,6 +19,7 @@ services:
|
||||
ports:
|
||||
- "${SERVER_PORT:-80}:80"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
||||
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
@@ -33,6 +34,8 @@ services:
|
||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
|
||||
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -46,12 +49,14 @@ services:
|
||||
ports:
|
||||
- "${MYSQL_PORT:-3307}:3306"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
||||
- MYSQL_DATABASE=polyhermes
|
||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
||||
interval: 10s
|
||||
|
||||
@@ -17,6 +17,7 @@ services:
|
||||
ports:
|
||||
- "${SERVER_PORT:-80}:80"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
||||
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
@@ -31,6 +32,8 @@ services:
|
||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
|
||||
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -44,12 +47,14 @@ services:
|
||||
ports:
|
||||
- "${MYSQL_PORT:-3307}:3306"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
||||
- MYSQL_DATABASE=polyhermes
|
||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
||||
interval: 10s
|
||||
|
||||
@@ -399,8 +399,8 @@ CopyOrderTrackingService.processTrade(
|
||||
### 5.1 application.properties
|
||||
|
||||
```properties
|
||||
# Polymarket WebSocket
|
||||
polymarket.websocket.url=wss://ws-live-data.polymarket.com
|
||||
# 注意:Polymarket API URL 现在使用代码常量(PolymarketConstants),不再从配置文件读取
|
||||
# 如需修改,请修改 com.wrbug.polymarketbot.constants.PolymarketConstants 类
|
||||
|
||||
# 监听策略
|
||||
copy.trading.monitor.strategy=dual
|
||||
|
||||
@@ -571,6 +571,8 @@
|
||||
"deleteConfirmDesc": "This action cannot be undone. Please ensure no copy trading relationships are using this template",
|
||||
"copySuccess": "Template copied successfully",
|
||||
"copyFailed": "Failed to copy template",
|
||||
"pushFilteredOrders": "Push Filtered Orders",
|
||||
"pushFilteredOrdersTooltip": "When enabled, filtered orders will be pushed to Telegram",
|
||||
"minAmountError": "Minimum amount must be >= 1",
|
||||
"fixedAmountRequired": "Please enter fixed copy trading amount",
|
||||
"invalidNumber": "Please enter a valid number",
|
||||
@@ -623,6 +625,8 @@
|
||||
"maxPricePlaceholder": "Max Price (leave empty for no limit)",
|
||||
"supportSell": "Support Sell",
|
||||
"supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.",
|
||||
"pushFilteredOrders": "Push Filtered Orders",
|
||||
"pushFilteredOrdersTooltip": "When enabled, filtered orders will be pushed to Telegram",
|
||||
"create": "Create Template",
|
||||
"createSuccess": "Template created successfully",
|
||||
"createFailed": "Failed to create template",
|
||||
@@ -687,6 +691,8 @@
|
||||
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
|
||||
"supportSell": "Support Sell",
|
||||
"supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.",
|
||||
"pushFilteredOrders": "Push Filtered Orders",
|
||||
"pushFilteredOrdersTooltip": "When enabled, filtered orders will be pushed to Telegram",
|
||||
"invalidNumber": "Please enter a valid number"
|
||||
},
|
||||
"copyTradingAdd": {
|
||||
@@ -783,6 +789,8 @@
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"pushFailedOrders": "Push Failed Orders",
|
||||
"pushFailedOrdersTooltip": "When enabled, failed orders will be pushed to Telegram",
|
||||
"pushFilteredOrders": "Push Filtered Orders",
|
||||
"pushFilteredOrdersTooltip": "When enabled, filtered orders will be pushed to Telegram",
|
||||
"autoRedeem": "Auto Redeem",
|
||||
"autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect",
|
||||
"builderApiKeyNotConfigured": "Builder API Key Not Configured",
|
||||
@@ -888,6 +896,8 @@
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"pushFailedOrders": "Push Failed Orders",
|
||||
"pushFailedOrdersTooltip": "When enabled, failed orders will be pushed to Telegram",
|
||||
"pushFilteredOrders": "Push Filtered Orders",
|
||||
"pushFilteredOrdersTooltip": "When enabled, filtered orders will be pushed to Telegram",
|
||||
"autoRedeem": "Auto Redeem",
|
||||
"autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect",
|
||||
"builderApiKeyNotConfigured": "Builder API Key Not Configured",
|
||||
|
||||
@@ -571,6 +571,8 @@
|
||||
"deleteConfirmDesc": "删除后无法恢复,请确保没有跟单关系在使用该模板",
|
||||
"copySuccess": "复制模板成功",
|
||||
"copyFailed": "复制模板失败",
|
||||
"pushFilteredOrders": "推送已过滤订单",
|
||||
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
|
||||
"minAmountError": "最小金额必须 >= 1",
|
||||
"fixedAmountRequired": "请输入固定跟单金额",
|
||||
"invalidNumber": "请输入有效的数字",
|
||||
@@ -623,6 +625,8 @@
|
||||
"maxPricePlaceholder": "最高价(留空不限制)",
|
||||
"supportSell": "跟单卖出",
|
||||
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
|
||||
"pushFilteredOrders": "推送已过滤订单",
|
||||
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
|
||||
"create": "创建模板",
|
||||
"createSuccess": "创建模板成功",
|
||||
"createFailed": "创建模板失败",
|
||||
@@ -687,6 +691,8 @@
|
||||
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
|
||||
"supportSell": "跟单卖出",
|
||||
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
|
||||
"pushFilteredOrders": "推送已过滤订单",
|
||||
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
|
||||
"invalidNumber": "请输入有效的数字"
|
||||
},
|
||||
"copyTradingAdd": {
|
||||
@@ -699,6 +705,8 @@
|
||||
"advancedSettings": "高级设置",
|
||||
"pushFailedOrders": "推送失败订单",
|
||||
"pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram",
|
||||
"pushFilteredOrders": "推送已过滤订单",
|
||||
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
|
||||
"autoRedeem": "自动赎回",
|
||||
"autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效",
|
||||
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
||||
@@ -812,6 +820,8 @@
|
||||
"advancedSettings": "高级设置",
|
||||
"pushFailedOrders": "推送失败订单",
|
||||
"pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram",
|
||||
"pushFilteredOrders": "推送已过滤订单",
|
||||
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
|
||||
"autoRedeem": "自动赎回",
|
||||
"autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效",
|
||||
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
||||
@@ -1089,6 +1099,7 @@
|
||||
"price": "价格",
|
||||
"amount": "金额",
|
||||
"filterMarketId": "筛选市场ID",
|
||||
"filterMarketTitle": "筛选市场标题",
|
||||
"filterSide": "筛选方向",
|
||||
"filterStatus": "筛选状态",
|
||||
"filterSellOrderId": "筛选卖出订单ID",
|
||||
|
||||
@@ -571,6 +571,8 @@
|
||||
"deleteConfirmDesc": "刪除後無法恢復,請確保沒有跟單關係在使用該模板",
|
||||
"copySuccess": "複製模板成功",
|
||||
"copyFailed": "複製模板失敗",
|
||||
"pushFilteredOrders": "推送已過濾訂單",
|
||||
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
|
||||
"minAmountError": "最小金額必須 >= 1",
|
||||
"fixedAmountRequired": "請輸入固定跟單金額",
|
||||
"invalidNumber": "請輸入有效的數字",
|
||||
@@ -623,6 +625,8 @@
|
||||
"maxPricePlaceholder": "最高價(留空不限制)",
|
||||
"supportSell": "跟單賣出",
|
||||
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
|
||||
"pushFilteredOrders": "推送已過濾訂單",
|
||||
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
|
||||
"create": "創建模板",
|
||||
"createSuccess": "創建模板成功",
|
||||
"createFailed": "創建模板失敗",
|
||||
@@ -687,6 +691,8 @@
|
||||
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
|
||||
"supportSell": "跟單賣出",
|
||||
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
|
||||
"pushFilteredOrders": "推送已過濾訂單",
|
||||
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
|
||||
"invalidNumber": "請輸入有效的數字"
|
||||
},
|
||||
"copyTradingAdd": {
|
||||
@@ -783,6 +789,8 @@
|
||||
"advancedSettings": "高級設置",
|
||||
"pushFailedOrders": "推送失敗訂單",
|
||||
"pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram",
|
||||
"pushFilteredOrders": "推送已過濾訂單",
|
||||
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
|
||||
"autoRedeem": "自動贖回",
|
||||
"autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效",
|
||||
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
||||
@@ -888,6 +896,8 @@
|
||||
"advancedSettings": "高級設置",
|
||||
"pushFailedOrders": "推送失敗訂單",
|
||||
"pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram",
|
||||
"pushFilteredOrders": "推送已過濾訂單",
|
||||
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
|
||||
"autoRedeem": "自動贖回",
|
||||
"autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效",
|
||||
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
||||
|
||||
@@ -120,7 +120,8 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
||||
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined,
|
||||
maxPositionValue: (template as any).maxPositionValue ? parseFloat((template as any).maxPositionValue) : undefined,
|
||||
maxPositionCount: (template as any).maxPositionCount
|
||||
maxPositionCount: (template as any).maxPositionCount,
|
||||
pushFilteredOrders: template.pushFilteredOrders ?? false
|
||||
})
|
||||
setCopyMode(template.copyMode)
|
||||
setTemplateModalVisible(false)
|
||||
@@ -255,6 +256,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
: undefined,
|
||||
configName: values.configName?.trim(),
|
||||
pushFailedOrders: values.pushFailedOrders ?? false,
|
||||
pushFilteredOrders: values.pushFilteredOrders ?? false,
|
||||
maxMarketEndDate
|
||||
}
|
||||
|
||||
@@ -307,6 +309,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
websocketMaxRetries: 10,
|
||||
supportSell: true,
|
||||
pushFailedOrders: false,
|
||||
pushFilteredOrders: false,
|
||||
keywordFilterMode: 'DISABLED'
|
||||
}}
|
||||
>
|
||||
@@ -804,12 +807,29 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
>
|
||||
<Input.Group compact style={{ display: 'flex' }}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
min={0}
|
||||
max={9999}
|
||||
step={1}
|
||||
precision={0}
|
||||
value={maxMarketEndDateValue}
|
||||
onChange={(value) => setMaxMarketEndDateValue(value !== null && value !== undefined ? Math.floor(value) : undefined)}
|
||||
onChange={(value) => {
|
||||
// 允许设置为 null 或 undefined(清空)
|
||||
if (value === null || value === undefined) {
|
||||
setMaxMarketEndDateValue(undefined)
|
||||
} else {
|
||||
const num = Math.floor(value)
|
||||
// 如果值为 0,也设置为 undefined(表示清空)
|
||||
setMaxMarketEndDateValue(num > 0 ? num : undefined)
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// 失去焦点时,如果值为 0 或空,设置为 undefined
|
||||
const input = e.target as HTMLInputElement
|
||||
const value = input.value
|
||||
if (!value || value === '0') {
|
||||
setMaxMarketEndDateValue(undefined)
|
||||
}
|
||||
}}
|
||||
style={{ width: '60%' }}
|
||||
placeholder={t('copyTradingAdd.maxMarketEndDatePlaceholder') || '输入时间值(可选)'}
|
||||
parser={(value) => {
|
||||
@@ -862,6 +882,16 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
{/* 推送已过滤订单 */}
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.pushFilteredOrders') || '推送已过滤订单'}
|
||||
name="pushFilteredOrders"
|
||||
tooltip={t('copyTradingAdd.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
|
||||
@@ -25,19 +25,44 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [filters, setFilters] = useState<{
|
||||
marketId?: string
|
||||
side?: string
|
||||
marketTitle?: string
|
||||
status?: string
|
||||
}>({})
|
||||
const [groupByMarket, setGroupByMarket] = useState(false) // 是否按市场分组
|
||||
// 从 localStorage 读取用户分组偏好,如果没有则默认为 false
|
||||
const [groupByMarket, setGroupByMarket] = useState(() => {
|
||||
const savedPreference = localStorage.getItem('copyTradingOrders_groupByMarket')
|
||||
return savedPreference === 'true'
|
||||
})
|
||||
const [expandedMarkets, setExpandedMarkets] = useState<Set<string>>(new Set()) // 展开的市场ID集合
|
||||
const [groupedOrders, setGroupedOrders] = useState<MarketOrderGroup[]>([]) // 分组数据(从后端获取)
|
||||
const [groupedTotal, setGroupedTotal] = useState(0) // 分组总数(市场数量)
|
||||
|
||||
const handleMarketTitleChange = (value: string) => {
|
||||
setFilters({ ...filters, marketTitle: value || undefined })
|
||||
}
|
||||
|
||||
// 切换分组模式时,保存用户偏好到 localStorage
|
||||
const handleToggleGroupByMarket = (checked: boolean) => {
|
||||
setGroupByMarket(checked)
|
||||
localStorage.setItem('copyTradingOrders_groupByMarket', String(checked))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId && active) {
|
||||
fetchOrders()
|
||||
}
|
||||
}, [copyTradingId, active, page, limit, filters, groupByMarket])
|
||||
}, [copyTradingId, active, page, limit, filters.marketId, filters.status, groupByMarket])
|
||||
|
||||
// 防抖搜索 - marketTitle 和 marketId 变化时延迟0.5秒再搜索
|
||||
useEffect(() => {
|
||||
if (!copyTradingId || !active) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
fetchOrders()
|
||||
}, 500) // 0.5秒延迟
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [filters.marketTitle])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
@@ -45,12 +70,14 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
setLoading(true)
|
||||
try {
|
||||
if (groupByMarket) {
|
||||
// 调用分组接口
|
||||
// 调用分组接口(支持市场筛选)
|
||||
const request: MarketGroupedOrdersRequest = {
|
||||
copyTradingId: parseInt(copyTradingId),
|
||||
type: 'buy',
|
||||
page,
|
||||
limit
|
||||
limit,
|
||||
marketId: filters.marketId,
|
||||
marketTitle: filters.marketTitle
|
||||
}
|
||||
|
||||
const response = await apiService.orderTracking.listGroupedByMarket(request)
|
||||
@@ -123,7 +150,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
const columns = [
|
||||
{
|
||||
title: t('copyTradingOrders.orderId') || '订单ID',
|
||||
dataIndex: 'orderId',
|
||||
@@ -208,16 +235,6 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -400,7 +417,6 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
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
|
||||
@@ -436,7 +452,6 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<Tag style={{ fontSize: '11px' }}>{displaySide}</Tag>
|
||||
{getStatusTag(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,18 +500,17 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
) : (
|
||||
<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
|
||||
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()
|
||||
|
||||
return (
|
||||
return (
|
||||
<Card
|
||||
key={order.orderId}
|
||||
style={{
|
||||
@@ -529,7 +543,6 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<Tag>{displaySide}</Tag>
|
||||
{getStatusTag(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -633,23 +646,20 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'}
|
||||
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') || '筛选方向'}
|
||||
<Input
|
||||
placeholder={t('copyTradingOrders.filterMarketTitle') || '筛选市场标题'}
|
||||
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>
|
||||
</Select>
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filters.marketTitle}
|
||||
onChange={(e) => handleMarketTitleChange(e.target.value)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
|
||||
@@ -669,7 +679,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
</span>
|
||||
<Switch
|
||||
checked={groupByMarket}
|
||||
onChange={setGroupByMarket}
|
||||
onChange={handleToggleGroupByMarket}
|
||||
checkedChildren={<AppstoreOutlined />}
|
||||
unCheckedChildren={<UnorderedListOutlined />}
|
||||
/>
|
||||
|
||||
@@ -91,7 +91,8 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
maxPositionCount: found.maxPositionCount,
|
||||
keywordFilterMode: found.keywordFilterMode || 'DISABLED',
|
||||
configName: found.configName || '',
|
||||
pushFailedOrders: found.pushFailedOrders ?? false
|
||||
pushFailedOrders: found.pushFailedOrders ?? false,
|
||||
pushFilteredOrders: found.pushFilteredOrders ?? false
|
||||
})
|
||||
// 设置关键字列表
|
||||
setKeywords(found.keywords || [])
|
||||
@@ -168,12 +169,17 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
}
|
||||
|
||||
// 计算市场截止时间(毫秒)
|
||||
// 如果用户清空了,传 -1 表示要清空(后端会识别并设置为 null)
|
||||
let maxMarketEndDate: number | undefined
|
||||
if (maxMarketEndDateValue !== undefined && maxMarketEndDateValue > 0) {
|
||||
if (maxMarketEndDateValue !== undefined && maxMarketEndDateValue !== null && maxMarketEndDateValue > 0) {
|
||||
const multiplier = maxMarketEndDateUnit === 'HOUR'
|
||||
? 60 * 60 * 1000 // 小时转毫秒
|
||||
: 24 * 60 * 60 * 1000 // 天转毫秒
|
||||
maxMarketEndDate = maxMarketEndDateValue * multiplier
|
||||
} else {
|
||||
// 如果值为 null/undefined/0/负数,传 -1 表示要清空
|
||||
// 这样无论之前是否有值,清空后都会设置为 null
|
||||
maxMarketEndDate = -1
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
@@ -195,18 +201,21 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
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(),
|
||||
maxPositionValue: values.maxPositionValue?.toString(),
|
||||
maxPositionCount: values.maxPositionCount,
|
||||
// 对于可选字段,始终发送(即使为空也发送空字符串,让后端知道要清空)
|
||||
minOrderDepth: values.minOrderDepth != null ? values.minOrderDepth.toString() : '',
|
||||
maxSpread: values.maxSpread != null ? values.maxSpread.toString() : '',
|
||||
minPrice: values.minPrice != null ? values.minPrice.toString() : '',
|
||||
maxPrice: values.maxPrice != null ? values.maxPrice.toString() : '',
|
||||
maxPositionValue: values.maxPositionValue != null ? values.maxPositionValue.toString() : '',
|
||||
// 对于 maxPositionCount,如果值为 null/undefined,传 -1 表示要清空(后端会识别并设置为 null)
|
||||
maxPositionCount: values.maxPositionCount != null ? values.maxPositionCount : -1,
|
||||
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
|
||||
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
|
||||
? keywords
|
||||
: undefined,
|
||||
configName: values.configName?.trim() || undefined,
|
||||
pushFailedOrders: values.pushFailedOrders,
|
||||
pushFilteredOrders: values.pushFilteredOrders,
|
||||
maxMarketEndDate
|
||||
}
|
||||
|
||||
@@ -722,12 +731,29 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
>
|
||||
<Input.Group compact style={{ display: 'flex' }}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
min={0}
|
||||
max={9999}
|
||||
step={1}
|
||||
precision={0}
|
||||
value={maxMarketEndDateValue}
|
||||
onChange={(value) => setMaxMarketEndDateValue(value !== null && value !== undefined ? Math.floor(value) : undefined)}
|
||||
onChange={(value) => {
|
||||
// 允许设置为 null 或 undefined(清空)
|
||||
if (value === null || value === undefined) {
|
||||
setMaxMarketEndDateValue(undefined)
|
||||
} else {
|
||||
const num = Math.floor(value)
|
||||
// 如果值为 0,也设置为 undefined(表示清空)
|
||||
setMaxMarketEndDateValue(num > 0 ? num : undefined)
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// 失去焦点时,如果值为 0 或空,设置为 undefined
|
||||
const input = e.target as HTMLInputElement
|
||||
const value = input.value
|
||||
if (!value || value === '0') {
|
||||
setMaxMarketEndDateValue(undefined)
|
||||
}
|
||||
}}
|
||||
style={{ width: '60%' }}
|
||||
placeholder={t('copyTradingEdit.maxMarketEndDatePlaceholder') || '输入时间值(可选)'}
|
||||
parser={(value) => {
|
||||
@@ -778,6 +804,15 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.pushFilteredOrders') || '推送已过滤订单'}
|
||||
name="pushFilteredOrders"
|
||||
tooltip={t('copyTradingEdit.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
|
||||
@@ -24,13 +24,29 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
||||
const [filters, setFilters] = useState<{
|
||||
sellOrderId?: string
|
||||
buyOrderId?: string
|
||||
marketTitle?: string
|
||||
}>({})
|
||||
|
||||
const handleMarketTitleChange = (value: string) => {
|
||||
setFilters({ ...filters, marketTitle: value || undefined })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId && active) {
|
||||
fetchOrders()
|
||||
}
|
||||
}, [copyTradingId, active, page, limit, filters])
|
||||
}, [copyTradingId, active, page, limit])
|
||||
|
||||
// 防抖搜索 - marketTitle 和 orderId 变化时延迟0.5秒再搜索
|
||||
useEffect(() => {
|
||||
if (!copyTradingId || !active) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
fetchOrders()
|
||||
}, 500) // 0.5秒延迟
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [filters.marketTitle, filters.buyOrderId, filters.sellOrderId])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
@@ -193,6 +209,14 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder={t('copyTradingOrders.filterMarketTitle') || '筛选市场标题'}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filters.marketTitle}
|
||||
onChange={(e) => handleMarketTitleChange(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder={t('copyTradingOrders.filterSellOrderId') || '筛选卖出订单ID'}
|
||||
allowClear
|
||||
|
||||
@@ -25,18 +25,44 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [filters, setFilters] = useState<{
|
||||
marketId?: string
|
||||
side?: string
|
||||
marketTitle?: string
|
||||
status?: string
|
||||
}>({})
|
||||
const [groupByMarket, setGroupByMarket] = useState(false) // 是否按市场分组
|
||||
// 从 localStorage 读取用户分组偏好,如果没有则默认为 false
|
||||
const [groupByMarket, setGroupByMarket] = useState(() => {
|
||||
const savedPreference = localStorage.getItem('copyTradingOrders_groupByMarket_sell')
|
||||
return savedPreference === 'true'
|
||||
})
|
||||
const [expandedMarkets, setExpandedMarkets] = useState<Set<string>>(new Set()) // 展开的市场ID集合
|
||||
const [groupedOrders, setGroupedOrders] = useState<MarketOrderGroup[]>([]) // 分组数据(从后端获取)
|
||||
const [groupedTotal, setGroupedTotal] = useState(0) // 分组总数(市场数量)
|
||||
|
||||
const handleMarketTitleChange = (value: string) => {
|
||||
setFilters({ ...filters, marketTitle: value || undefined })
|
||||
}
|
||||
|
||||
// 切换分组模式时,保存用户偏好到 localStorage
|
||||
const handleToggleGroupByMarket = (checked: boolean) => {
|
||||
setGroupByMarket(checked)
|
||||
localStorage.setItem('copyTradingOrders_groupByMarket_sell', String(checked))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId && active) {
|
||||
fetchOrders()
|
||||
}
|
||||
}, [copyTradingId, active, page, limit, filters, groupByMarket])
|
||||
}, [copyTradingId, active, page, limit, filters.marketId, filters.status, groupByMarket])
|
||||
|
||||
// 防抖搜索 - marketTitle 变化时延迟0.5秒再搜索
|
||||
useEffect(() => {
|
||||
if (!copyTradingId || !active) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
fetchOrders()
|
||||
}, 500) // 0.5秒延迟
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [filters.marketTitle])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
@@ -49,7 +75,9 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
copyTradingId: parseInt(copyTradingId),
|
||||
type: 'sell',
|
||||
page,
|
||||
limit
|
||||
limit,
|
||||
marketId: filters.marketId,
|
||||
marketTitle: filters.marketTitle
|
||||
}
|
||||
|
||||
const response = await apiService.orderTracking.listGroupedByMarket(request)
|
||||
@@ -204,16 +232,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -378,7 +396,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
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
|
||||
@@ -413,9 +430,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<Tag style={{ fontSize: '11px' }}>{displaySide}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
@@ -473,7 +487,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
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
|
||||
@@ -507,9 +520,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<Tag>{displaySide}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
@@ -622,17 +632,24 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder={t('copyTradingOrders.filterMarketTitle') || '筛选市场标题'}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filters.marketTitle}
|
||||
onChange={(e) => handleMarketTitleChange(e.target.value)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
|
||||
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 150 }}
|
||||
value={filters.side}
|
||||
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
||||
>
|
||||
<Option value="0">YES</Option>
|
||||
<Option value="1">NO</Option>
|
||||
<Option value="YES">YES</Option>
|
||||
<Option value="NO">NO</Option>
|
||||
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
|
||||
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallyMatched') || '部分成交'}</Option>
|
||||
<Option value="fully_matched">{t('copyTradingOrders.statusFullyMatched') || '完全成交'}</Option>
|
||||
</Select>
|
||||
|
||||
<Space>
|
||||
@@ -641,7 +658,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
</span>
|
||||
<Switch
|
||||
checked={groupByMarket}
|
||||
onChange={setGroupByMarket}
|
||||
onChange={handleToggleGroupByMarket}
|
||||
checkedChildren={<AppstoreOutlined />}
|
||||
unCheckedChildren={<UnorderedListOutlined />}
|
||||
/>
|
||||
|
||||
@@ -55,7 +55,8 @@ const TemplateAdd: React.FC = () => {
|
||||
minOrderDepth: values.minOrderDepth?.toString(),
|
||||
maxSpread: values.maxSpread?.toString(),
|
||||
minPrice: values.minPrice?.toString(),
|
||||
maxPrice: values.maxPrice?.toString()
|
||||
maxPrice: values.maxPrice?.toString(),
|
||||
pushFilteredOrders: values.pushFilteredOrders ?? false
|
||||
})
|
||||
|
||||
if (response.data.code === 0) {
|
||||
@@ -96,7 +97,8 @@ const TemplateAdd: React.FC = () => {
|
||||
minOrderSize: 1,
|
||||
maxDailyOrders: 100,
|
||||
priceTolerance: 5,
|
||||
supportSell: true
|
||||
supportSell: true,
|
||||
pushFilteredOrders: false
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
@@ -373,6 +375,15 @@ const TemplateAdd: React.FC = () => {
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('templateAdd.pushFilteredOrders') || '推送已过滤订单'}
|
||||
name="pushFilteredOrders"
|
||||
tooltip={t('templateAdd.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item shouldUpdate>
|
||||
{({ getFieldsError }) => {
|
||||
const errors = getFieldsError()
|
||||
|
||||
@@ -41,7 +41,8 @@ const TemplateEdit: React.FC = () => {
|
||||
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
|
||||
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
|
||||
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
||||
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined
|
||||
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined,
|
||||
pushFilteredOrders: template.pushFilteredOrders ?? false
|
||||
})
|
||||
} else {
|
||||
message.error(response.data.msg || t('templateEdit.fetchFailed') || '获取模板详情失败')
|
||||
@@ -99,7 +100,8 @@ const TemplateEdit: React.FC = () => {
|
||||
minOrderDepth: values.minOrderDepth?.toString(),
|
||||
maxSpread: values.maxSpread?.toString(),
|
||||
minPrice: values.minPrice?.toString(),
|
||||
maxPrice: values.maxPrice?.toString()
|
||||
maxPrice: values.maxPrice?.toString(),
|
||||
pushFilteredOrders: values.pushFilteredOrders
|
||||
})
|
||||
|
||||
if (response.data.code === 0) {
|
||||
@@ -409,6 +411,15 @@ const TemplateEdit: React.FC = () => {
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('templateEdit.pushFilteredOrders') || '推送已过滤订单'}
|
||||
name="pushFilteredOrders"
|
||||
tooltip={t('templateEdit.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item shouldUpdate>
|
||||
{({ getFieldsError }) => {
|
||||
const errors = getFieldsError()
|
||||
|
||||
@@ -72,6 +72,7 @@ const TemplateList: React.FC = () => {
|
||||
maxDailyOrders: template.maxDailyOrders,
|
||||
priceTolerance: parseFloat(template.priceTolerance),
|
||||
supportSell: template.supportSell,
|
||||
pushFilteredOrders: template.pushFilteredOrders ?? false,
|
||||
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
|
||||
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
|
||||
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
||||
@@ -122,7 +123,8 @@ const TemplateList: React.FC = () => {
|
||||
minOrderDepth: values.minOrderDepth?.toString(),
|
||||
maxSpread: values.maxSpread?.toString(),
|
||||
minPrice: values.minPrice?.toString(),
|
||||
maxPrice: values.maxPrice?.toString()
|
||||
maxPrice: values.maxPrice?.toString(),
|
||||
pushFilteredOrders: values.pushFilteredOrders ?? false
|
||||
})
|
||||
|
||||
if (response.data.code === 0) {
|
||||
@@ -631,6 +633,15 @@ const TemplateList: React.FC = () => {
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('templateList.pushFilteredOrders') || '推送已过滤订单'}
|
||||
name="pushFilteredOrders"
|
||||
tooltip={t('templateList.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>过滤条件(可选)</Divider>
|
||||
|
||||
<Form.Item
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface CopyTradingTemplate {
|
||||
maxSpread?: string
|
||||
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
|
||||
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
||||
pushFilteredOrders?: boolean // 推送已过滤订单(默认关闭)
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
@@ -266,6 +267,7 @@ export interface CopyTradingCreateRequest {
|
||||
// 新增配置字段
|
||||
configName?: string // 配置名(可选,但提供时必须非空)
|
||||
pushFailedOrders?: boolean // 推送失败订单(可选)
|
||||
pushFilteredOrders?: boolean // 推送已过滤订单(可选)
|
||||
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||
}
|
||||
|
||||
@@ -304,6 +306,7 @@ export interface CopyTradingUpdateRequest {
|
||||
// 新增配置字段
|
||||
configName?: string // 配置名(可选,但提供时必须非空)
|
||||
pushFailedOrders?: boolean // 推送失败订单(可选)
|
||||
pushFilteredOrders?: boolean // 推送已过滤订单(可选)
|
||||
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||
}
|
||||
|
||||
@@ -670,6 +673,7 @@ export interface SellOrderInfo {
|
||||
price: string
|
||||
amount: string
|
||||
realizedPnl: string
|
||||
status?: string // 卖出状态(filled, partially_matched, fully_matched)
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
@@ -710,7 +714,7 @@ export interface OrderTrackingRequest {
|
||||
page?: number
|
||||
limit?: number
|
||||
marketId?: string
|
||||
side?: string
|
||||
marketTitle?: string
|
||||
status?: string
|
||||
sellOrderId?: string
|
||||
buyOrderId?: string
|
||||
@@ -724,6 +728,8 @@ export interface MarketGroupedOrdersRequest {
|
||||
type: 'buy' | 'sell'
|
||||
page?: number
|
||||
limit?: number
|
||||
marketId?: string
|
||||
marketTitle?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user