Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
662aa47de6 | ||
|
|
c3d9d10d5d | ||
|
|
9926533049 | ||
|
|
4d72017b97 | ||
|
|
45734c051e | ||
|
|
db8471bb16 | ||
|
|
0dc6f5894f | ||
|
|
c9769aa17a | ||
|
|
07b4d654b4 | ||
|
|
b65827038f | ||
|
|
d768da72c6 | ||
|
|
7385efff1a | ||
|
|
3e2e97e572 | ||
|
|
ae68a33c1e | ||
|
|
deea59fdbf | ||
|
|
b90f86b081 | ||
|
|
f6f5866118 | ||
|
|
b1e69135b8 | ||
|
|
0c7f34a28a | ||
|
|
c53fcde5d7 | ||
|
|
81a620af12 | ||
|
|
5f44a0ca20 | ||
|
|
5d2cf945f3 | ||
|
|
227a38fa89 | ||
|
|
abcc004606 | ||
|
|
64391503c2 | ||
|
|
92b75d1926 | ||
|
|
e072d0c894 | ||
|
|
cc40493ec6 | ||
|
|
3a78a84610 | ||
|
|
692cbd9a80 | ||
|
|
8f01588221 | ||
|
|
f6fc836e79 | ||
|
|
9c303e0a82 | ||
|
|
cb8e46919f | ||
|
|
279806da2b | ||
|
|
90fa487d1b | ||
|
|
b58bb267a1 | ||
|
|
6af76c4d80 | ||
|
|
43de0104e2 | ||
|
|
2ccab42894 | ||
|
|
3008cbcb50 | ||
|
|
42a318b501 | ||
|
|
ecb737ec67 | ||
|
|
0f3baec6cb | ||
|
|
764d684846 |
@@ -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
|
WORKDIR /app
|
||||||
|
|
||||||
# 安装 Nginx 和必要的工具
|
# 安装 Nginx 和必要的工具(包含时区数据)
|
||||||
RUN apt-get update && \
|
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 /var/lib/apt/lists/* && \
|
||||||
rm -rf /etc/nginx/sites-enabled/default
|
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
|
# v1.1.9
|
||||||
|
|
||||||
## 🐛 Bug 修复
|
## 🐛 Bug 修复
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export PROXY_PORT=8888
|
|||||||
- 代理配置错误
|
- 代理配置错误
|
||||||
|
|
||||||
**排查步骤**:
|
**排查步骤**:
|
||||||
1. 检查 `polymarket.rtds.ws-url` 配置是否正确
|
1. 检查 Polymarket RTDS WebSocket URL(现在使用代码常量 `PolymarketConstants.RTDS_WS_URL`)
|
||||||
2. 检查网络连接
|
2. 检查网络连接
|
||||||
3. 查看详细错误日志
|
3. 查看详细错误日志
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-8000}:8000"
|
- "${SERVER_PORT:-8000}:8000"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
- 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_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
||||||
- DB_USERNAME=${DB_USERNAME:-root}
|
- DB_USERNAME=${DB_USERNAME:-root}
|
||||||
@@ -16,6 +17,8 @@ services:
|
|||||||
- SERVER_PORT=8000
|
- SERVER_PORT=8000
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||||
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -29,12 +32,14 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${MYSQL_PORT:-3306}:3306"
|
- "${MYSQL_PORT:-3306}:3306"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
||||||
- MYSQL_DATABASE=polyhermes
|
- MYSQL_DATABASE=polyhermes
|
||||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||||
volumes:
|
volumes:
|
||||||
- mysql-data:/var/lib/mysql
|
- mysql-data:/var/lib/mysql
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package com.wrbug.polymarketbot.config
|
|||||||
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@@ -23,9 +23,6 @@ class RetrofitConfig(
|
|||||||
private val gson: Gson
|
private val gson: Gson
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Value("\${polymarket.clob.base-url}")
|
|
||||||
private lateinit var clobBaseUrl: String
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 CLOB API 客户端
|
* 创建 CLOB API 客户端
|
||||||
* 用于跟单系统的订单操作和交易查询
|
* 用于跟单系统的订单操作和交易查询
|
||||||
@@ -38,7 +35,7 @@ class RetrofitConfig(
|
|||||||
val okHttpClient = createClient().build()
|
val okHttpClient = createClient().build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.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 configName: String? = null, // 配置名(可选)
|
||||||
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
||||||
|
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
|
||||||
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ data class CopyTradingUpdateRequest(
|
|||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
val configName: String? = null, // 配置名(可选,但提供时必须非空)
|
val configName: String? = null, // 配置名(可选,但提供时必须非空)
|
||||||
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
||||||
|
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
|
||||||
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -156,6 +158,7 @@ data class CopyTradingDto(
|
|||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
val configName: String? = null, // 配置名(可选)
|
val configName: String? = null, // 配置名(可选)
|
||||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||||
|
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||||
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val updatedAt: Long
|
val updatedAt: Long
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ data class OrderTrackingRequest(
|
|||||||
val page: Int? = 1,
|
val page: Int? = 1,
|
||||||
val limit: Int? = 20,
|
val limit: Int? = 20,
|
||||||
val marketId: String? = null,
|
val marketId: String? = null,
|
||||||
val side: String? = null,
|
val marketTitle: String? = null, // 市场标题关键字筛选
|
||||||
val status: String? = null,
|
val status: String? = null,
|
||||||
val sellOrderId: String? = null,
|
val sellOrderId: String? = null,
|
||||||
val buyOrderId: String? = null
|
val buyOrderId: String? = null
|
||||||
@@ -123,7 +123,9 @@ data class MarketGroupedOrdersRequest(
|
|||||||
val copyTradingId: Long,
|
val copyTradingId: Long,
|
||||||
val type: String, // buy, sell, matched
|
val type: String, // buy, sell, matched
|
||||||
val page: Int? = 1,
|
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 minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||||
val minPrice: 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 minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||||
val minPrice: 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 minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||||
val minPrice: 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 maxSpread: String?,
|
||||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
val pushFilteredOrders: Boolean, // 推送已过滤订单(默认关闭)
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val updatedAt: Long
|
val updatedAt: Long
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ data class CopyOrderTracking(
|
|||||||
|
|
||||||
@Column(name = "notification_sent", nullable = false)
|
@Column(name = "notification_sent", nullable = false)
|
||||||
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
|
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
|
||||||
|
|
||||||
|
@Column(name = "source", nullable = false, length = 20)
|
||||||
|
val source: String, // 订单来源:activity-ws(Polymarket WebSocket)、onchain-ws(OnChain WebSocket)
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ data class CopyTrading(
|
|||||||
@Column(name = "push_failed_orders", nullable = false)
|
@Column(name = "push_failed_orders", nullable = false)
|
||||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||||
|
|
||||||
|
@Column(name = "push_filtered_orders", nullable = false)
|
||||||
|
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||||
|
|
||||||
@Column(name = "max_market_end_date")
|
@Column(name = "max_market_end_date")
|
||||||
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ data class CopyTradingTemplate(
|
|||||||
@Column(name = "max_price", precision = 20, scale = 8)
|
@Column(name = "max_price", precision = 20, scale = 8)
|
||||||
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
|
||||||
|
@Column(name = "push_filtered_orders", nullable = false)
|
||||||
|
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
+4
-8
@@ -75,15 +75,11 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
|||||||
fun countActivePositions(copyTradingId: Long): Int
|
fun countActivePositions(copyTradingId: Long): Int
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查指定市场是否存在活跃仓位
|
* 计算指定跟单配置、市场和方向下的当前持仓总价值 (成本价计算)
|
||||||
|
* 按市场+方向(outcomeIndex)分别统计
|
||||||
*/
|
*/
|
||||||
fun existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(copyTradingId: Long, marketId: String, remainingQuantity: BigDecimal): Boolean
|
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0")
|
||||||
|
fun sumCurrentPositionValueByMarketAndOutcomeIndex(copyTradingId: Long, marketId: String, outcomeIndex: Int): BigDecimal?
|
||||||
/**
|
|
||||||
* 计算指定跟单配置和市场下的当前持仓总价值 (成本价计算)
|
|
||||||
*/
|
|
||||||
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.remainingQuantity > 0")
|
|
||||||
fun sumCurrentPositionValueByMarket(copyTradingId: Long, marketId: String): BigDecimal?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
|
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
|
||||||
|
|||||||
@@ -948,6 +948,9 @@ class AccountService(
|
|||||||
java.util.Locale("zh", "CN") // 默认简体中文
|
java.util.Locale("zh", "CN") // 默认简体中文
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用当前时间作为订单创建时间
|
||||||
|
val orderTime = System.currentTimeMillis()
|
||||||
|
|
||||||
telegramNotificationService?.sendOrderSuccessNotification(
|
telegramNotificationService?.sendOrderSuccessNotification(
|
||||||
orderId = orderId,
|
orderId = orderId,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
@@ -963,7 +966,8 @@ class AccountService(
|
|||||||
apiSecret = try { cryptoUtils.decrypt(account.apiSecret!!) } catch (e: Exception) { null },
|
apiSecret = try { cryptoUtils.decrypt(account.apiSecret!!) } catch (e: Exception) { null },
|
||||||
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
|
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
|
||||||
walletAddressForApi = account.walletAddress,
|
walletAddressForApi = account.walletAddress,
|
||||||
locale = locale
|
locale = locale,
|
||||||
|
orderTime = orderTime // 使用订单创建时间
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("发送订单成功通知失败: ${e.message}", e)
|
logger.warn("发送订单成功通知失败: ${e.message}", e)
|
||||||
|
|||||||
+16
-18
@@ -21,29 +21,29 @@ import java.util.concurrent.CopyOnWriteArrayList
|
|||||||
class PositionPollingService(
|
class PositionPollingService(
|
||||||
private val accountService: AccountService
|
private val accountService: AccountService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
|
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
|
||||||
|
|
||||||
@Value("\${position.polling.interval:2000}")
|
@Value("\${position.polling.interval:2000}")
|
||||||
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
|
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
|
||||||
|
|
||||||
// 订阅者列表(支持多个订阅者)
|
// 订阅者列表(支持多个订阅者)
|
||||||
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
|
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
|
||||||
|
|
||||||
// 最新仓位数据(用于丢弃机制)
|
// 最新仓位数据(用于丢弃机制)
|
||||||
@Volatile
|
@Volatile
|
||||||
private var latestPositions: PositionListResponse? = null
|
private var latestPositions: PositionListResponse? = null
|
||||||
|
|
||||||
// 协程作用域和任务
|
// 协程作用域和任务
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
private var pollingJob: Job? = null
|
private var pollingJob: Job? = null
|
||||||
|
|
||||||
// 事件分发协程(使用专门的线程,避免阻塞轮训)
|
// 事件分发协程(使用专门的线程,避免阻塞轮训)
|
||||||
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
// 同步锁,确保轮询任务的启动和停止是线程安全的
|
// 同步锁,确保轮询任务的启动和停止是线程安全的
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化服务(后端启动时直接启动轮训)
|
* 初始化服务(后端启动时直接启动轮训)
|
||||||
*/
|
*/
|
||||||
@@ -52,7 +52,7 @@ class PositionPollingService(
|
|||||||
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
|
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理资源
|
* 清理资源
|
||||||
*/
|
*/
|
||||||
@@ -66,7 +66,7 @@ class PositionPollingService(
|
|||||||
scope.cancel()
|
scope.cancel()
|
||||||
eventDispatcherScope.cancel()
|
eventDispatcherScope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅仓位事件
|
* 订阅仓位事件
|
||||||
* @param callback 回调函数,接收最新的仓位数据
|
* @param callback 回调函数,接收最新的仓位数据
|
||||||
@@ -78,7 +78,7 @@ class PositionPollingService(
|
|||||||
latestPositions?.let { callback(it) }
|
latestPositions?.let { callback(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消订阅仓位事件
|
* 取消订阅仓位事件
|
||||||
*/
|
*/
|
||||||
@@ -87,7 +87,7 @@ class PositionPollingService(
|
|||||||
subscribers.remove(callback)
|
subscribers.remove(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动轮训任务
|
* 启动轮训任务
|
||||||
*/
|
*/
|
||||||
@@ -95,7 +95,7 @@ class PositionPollingService(
|
|||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
// 如果已经有轮训任务在运行,先取消
|
// 如果已经有轮训任务在运行,先取消
|
||||||
pollingJob?.cancel()
|
pollingJob?.cancel()
|
||||||
|
|
||||||
// 启动新的轮训任务
|
// 启动新的轮训任务
|
||||||
pollingJob = scope.launch {
|
pollingJob = scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
@@ -109,7 +109,7 @@ class PositionPollingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轮训仓位数据并发布事件
|
* 轮训仓位数据并发布事件
|
||||||
* 使用专门的线程分发事件,避免阻塞轮训
|
* 使用专门的线程分发事件,避免阻塞轮训
|
||||||
@@ -123,7 +123,7 @@ class PositionPollingService(
|
|||||||
if (positions != null) {
|
if (positions != null) {
|
||||||
// 更新最新数据(丢弃旧数据,只保留最新的)
|
// 更新最新数据(丢弃旧数据,只保留最新的)
|
||||||
latestPositions = positions
|
latestPositions = positions
|
||||||
|
|
||||||
// 在专门的线程中分发事件,避免阻塞轮训
|
// 在专门的线程中分发事件,避免阻塞轮训
|
||||||
eventDispatcherScope.launch {
|
eventDispatcherScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -131,7 +131,7 @@ class PositionPollingService(
|
|||||||
val currentSubscribers = synchronized(lock) {
|
val currentSubscribers = synchronized(lock) {
|
||||||
subscribers.toList() // 复制列表,避免并发修改
|
subscribers.toList() // 复制列表,避免并发修改
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSubscribers.forEach { callback ->
|
currentSubscribers.forEach { callback ->
|
||||||
try {
|
try {
|
||||||
callback(positions)
|
callback(positions)
|
||||||
@@ -139,8 +139,6 @@ class PositionPollingService(
|
|||||||
logger.error("通知订阅者失败: ${e.message}", e)
|
logger.error("通知订阅者失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("发布仓位数据事件: currentPositions=${positions.currentPositions.size}, historyPositions=${positions.historyPositions.size}, subscribers=${currentSubscribers.size}")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("分发仓位数据事件失败: ${e.message}", e)
|
logger.error("分发仓位数据事件失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import com.wrbug.polymarketbot.api.JsonRpcResponse
|
|||||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||||
import com.wrbug.polymarketbot.api.PositionResponse
|
import com.wrbug.polymarketbot.api.PositionResponse
|
||||||
import com.wrbug.polymarketbot.api.ValueResponse
|
import com.wrbug.polymarketbot.api.ValueResponse
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -26,8 +26,6 @@ import java.math.BigInteger
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class BlockchainService(
|
class BlockchainService(
|
||||||
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
|
|
||||||
private val dataApiBaseUrl: String,
|
|
||||||
private val retrofitFactory: RetrofitFactory,
|
private val retrofitFactory: RetrofitFactory,
|
||||||
private val relayClientService: RelayClientService,
|
private val relayClientService: RelayClientService,
|
||||||
private val rpcNodeService: RpcNodeService,
|
private val rpcNodeService: RpcNodeService,
|
||||||
@@ -61,10 +59,10 @@ class BlockchainService(
|
|||||||
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
|
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
|
||||||
|
|
||||||
private val dataApi: PolymarketDataApi by lazy {
|
private val dataApi: PolymarketDataApi by lazy {
|
||||||
val baseUrl = if (dataApiBaseUrl.endsWith("/")) {
|
val baseUrl = if (PolymarketConstants.DATA_API_BASE_URL.endsWith("/")) {
|
||||||
dataApiBaseUrl.dropLast(1)
|
PolymarketConstants.DATA_API_BASE_URL.dropLast(1)
|
||||||
} else {
|
} else {
|
||||||
dataApiBaseUrl
|
PolymarketConstants.DATA_API_BASE_URL
|
||||||
}
|
}
|
||||||
val okHttpClient = createClient()
|
val okHttpClient = createClient()
|
||||||
.followRedirects(true)
|
.followRedirects(true)
|
||||||
|
|||||||
@@ -89,8 +89,6 @@ class MarketPollingService(
|
|||||||
*/
|
*/
|
||||||
private suspend fun checkAndUpdateMissingMarkets() {
|
private suspend fun checkAndUpdateMissingMarkets() {
|
||||||
try {
|
try {
|
||||||
logger.debug("开始检查缺失的市场信息...")
|
|
||||||
|
|
||||||
// 1. 获取所有买入订单的市场ID(去重)
|
// 1. 获取所有买入订单的市场ID(去重)
|
||||||
val allOrders = copyOrderTrackingRepository.findAll()
|
val allOrders = copyOrderTrackingRepository.findAll()
|
||||||
val marketIds = allOrders.map { it.marketId }.distinct()
|
val marketIds = allOrders.map { it.marketId }.distinct()
|
||||||
@@ -99,9 +97,6 @@ class MarketPollingService(
|
|||||||
logger.debug("没有找到任何订单,跳过市场信息检查")
|
logger.debug("没有找到任何订单,跳过市场信息检查")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
|
|
||||||
|
|
||||||
// 2. 检查哪些市场信息在数据库中缺失
|
// 2. 检查哪些市场信息在数据库中缺失
|
||||||
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
||||||
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
||||||
@@ -113,7 +108,6 @@ class MarketPollingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (validMissingMarketIds.isEmpty()) {
|
if (validMissingMarketIds.isEmpty()) {
|
||||||
logger.debug("所有市场信息都已存在,无需更新")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-5
@@ -3,12 +3,12 @@ package com.wrbug.polymarketbot.service.common
|
|||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
||||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
@@ -19,8 +19,6 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class PolymarketApiKeyService(
|
class PolymarketApiKeyService(
|
||||||
@Value("\${polymarket.clob.base-url}")
|
|
||||||
private val clobBaseUrl: String,
|
|
||||||
private val gson: Gson
|
private val gson: Gson
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -224,7 +222,7 @@ class PolymarketApiKeyService(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
@@ -238,7 +236,7 @@ class PolymarketApiKeyService(
|
|||||||
val okHttpClient = createClient().build()
|
val okHttpClient = createClient().build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
+59
-33
@@ -45,7 +45,8 @@ class CopyTradingFilterService(
|
|||||||
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
||||||
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
||||||
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
||||||
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
|
marketEndDate: Long? = null, // 市场截止时间,用于市场截止时间检查
|
||||||
|
outcomeIndex: Int? = null // 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||||
): FilterResult {
|
): FilterResult {
|
||||||
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
||||||
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
||||||
@@ -71,12 +72,20 @@ class CopyTradingFilterService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 检查是否需要获取订单簿
|
// 3. 检查是否需要获取订单簿或需要执行仓位检查
|
||||||
// 只有在配置了需要订单簿的过滤条件时才获取
|
// 只有在配置了需要订单簿的过滤条件时才获取订单簿
|
||||||
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
|
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
|
||||||
|
|
||||||
|
// 3.5. 如果不需要订单簿,则跳过订单簿相关的检查,但仍然需要检查仓位限制
|
||||||
if (!needOrderbook) {
|
if (!needOrderbook) {
|
||||||
// 不需要订单簿,直接通过
|
// 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||||
|
if (copyOrderAmount != null && marketId != null) {
|
||||||
|
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||||
|
if (!positionCheck.isPassed) {
|
||||||
|
return positionCheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 通过所有检查
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +117,7 @@ class CopyTradingFilterService(
|
|||||||
|
|
||||||
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||||
if (copyOrderAmount != null && marketId != null) {
|
if (copyOrderAmount != null && marketId != null) {
|
||||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||||
if (!positionCheck.isPassed) {
|
if (!positionCheck.isPassed) {
|
||||||
return positionCheck
|
return positionCheck
|
||||||
}
|
}
|
||||||
@@ -283,87 +292,104 @@ class CopyTradingFilterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查仓位限制(按市场检查)
|
* 检查仓位限制(按市场+方向检查)
|
||||||
* @param copyTrading 跟单配置
|
* @param copyTrading 跟单配置
|
||||||
* @param copyOrderAmount 跟单金额(USDC)
|
* @param copyOrderAmount 跟单金额(USDC)
|
||||||
* @param marketId 市场ID,用于过滤该市场的仓位
|
* @param marketId 市场ID,用于过滤该市场的仓位
|
||||||
|
* @param outcomeIndex 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||||
* @return 过滤结果
|
* @return 过滤结果
|
||||||
*/
|
*/
|
||||||
private suspend fun checkPositionLimits(
|
private suspend fun checkPositionLimits(
|
||||||
copyTrading: CopyTrading,
|
copyTrading: CopyTrading,
|
||||||
copyOrderAmount: BigDecimal,
|
copyOrderAmount: BigDecimal,
|
||||||
marketId: String
|
marketId: String,
|
||||||
|
outcomeIndex: Int?
|
||||||
): FilterResult {
|
): FilterResult {
|
||||||
// 如果未配置仓位限制,直接通过
|
// 如果未配置仓位限制,直接通过
|
||||||
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取账户的所有仓位信息
|
// 获取账户的所有仓位信息
|
||||||
val positionsResult = accountService.getAllPositions()
|
val positionsResult = accountService.getAllPositions()
|
||||||
if (positionsResult.isFailure) {
|
if (positionsResult.isFailure) {
|
||||||
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, error=${positionsResult.exceptionOrNull()?.message}")
|
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${positionsResult.exceptionOrNull()?.message}")
|
||||||
// 如果获取仓位失败,为了安全起见,不通过检查
|
// 如果获取仓位失败,为了安全起见,不通过检查
|
||||||
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
|
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
|
||||||
}
|
}
|
||||||
|
|
||||||
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
||||||
|
|
||||||
// 过滤出当前账户且该市场的仓位
|
// 过滤出当前账户且该市场的仓位
|
||||||
val marketPositions = positions.currentPositions.filter {
|
val marketPositions = positions.currentPositions.filter {
|
||||||
it.accountId == copyTrading.accountId && it.marketId == marketId
|
it.accountId == copyTrading.accountId && it.marketId == marketId
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查最大仓位金额(如果配置了)
|
// 检查最大仓位金额(如果配置了)
|
||||||
if (copyTrading.maxPositionValue != null) {
|
if (copyTrading.maxPositionValue != null && outcomeIndex != null) {
|
||||||
// 比较数据库成本价(本地订单记录)和外部持仓市值(可能来自其他终端的操作),取最大值
|
// 按市场+方向(outcomeIndex)分别计算数据库成本价
|
||||||
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarket(copyTrading.id!!, marketId) ?: BigDecimal.ZERO
|
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarketAndOutcomeIndex(
|
||||||
val extValue = marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
copyTrading.id!!, marketId, outcomeIndex
|
||||||
|
) ?: BigDecimal.ZERO
|
||||||
|
|
||||||
|
// 外部持仓也需要按方向过滤,但由于外部持仓可能没有 outcomeIndex 信息,这里保守处理:
|
||||||
|
// 如果外部持仓存在,取该市场的所有外部持仓市值(与数据库取最大值)
|
||||||
|
val extValue = if (marketPositions.isNotEmpty()) {
|
||||||
|
marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
||||||
|
} else {
|
||||||
|
BigDecimal.ZERO
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取数据库值和外部持仓值的最大值
|
||||||
val currentPositionValue = dbValue.max(extValue)
|
val currentPositionValue = dbValue.max(extValue)
|
||||||
|
|
||||||
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
|
// 检查:该市场该方向的当前仓位 + 跟单金额 <= 最大仓位金额
|
||||||
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
||||||
|
|
||||||
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
||||||
return FilterResult.maxPositionValueFailed(
|
return FilterResult.maxPositionValueFailed(
|
||||||
"超过最大仓位金额限制: 当前该市场仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
"超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查最大仓位数量(如果配置了)
|
// 检查最大仓位数量(如果配置了)
|
||||||
if (copyTrading.maxPositionCount != null) {
|
if (copyTrading.maxPositionCount != null) {
|
||||||
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
|
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
|
||||||
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
|
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
|
||||||
|
|
||||||
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
|
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
|
||||||
val extCount = positions.currentPositions
|
val extCount = positions.currentPositions
|
||||||
.filter { it.accountId == copyTrading.accountId }
|
.filter { it.accountId == copyTrading.accountId }
|
||||||
.map { it.marketId }
|
.map { it.marketId }
|
||||||
.distinct()
|
.distinct()
|
||||||
.size
|
.size
|
||||||
|
|
||||||
val currentPositionCount = maxOf(dbCount, extCount)
|
val currentPositionCount = maxOf(dbCount, extCount)
|
||||||
|
|
||||||
// 检查:如果当前没有该市场的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
// 检查:如果当前没有该市场该方向的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
||||||
// 判断当前市场是否已有活跃仓位(数据库或外部持仓)
|
// 判断当前市场该方向是否已有活跃仓位(数据库)
|
||||||
val hasDbPosition = copyOrderTrackingRepository.existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(
|
val hasDbPosition = if (outcomeIndex != null) {
|
||||||
copyTrading.id, marketId, BigDecimal.ZERO
|
copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||||
)
|
copyTrading.id, marketId, outcomeIndex
|
||||||
|
).isNotEmpty()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
val hasExtPosition = marketPositions.isNotEmpty()
|
val hasExtPosition = marketPositions.isNotEmpty()
|
||||||
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
|
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
|
||||||
|
|
||||||
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
|
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
|
||||||
return FilterResult.maxPositionCountFailed(
|
return FilterResult.maxPositionCountFailed(
|
||||||
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
|
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, error=${e.message}", e)
|
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
|
||||||
// 如果检查异常,为了安全起见,不通过检查
|
// 如果检查异常,为了安全起见,不通过检查
|
||||||
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-14
@@ -10,9 +10,12 @@ import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
|||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
|
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
|
import com.wrbug.polymarketbot.util.IllegalBigDecimal
|
||||||
import com.wrbug.polymarketbot.util.JsonUtils
|
import com.wrbug.polymarketbot.util.JsonUtils
|
||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.context.ApplicationContext
|
||||||
|
import org.springframework.context.ApplicationContextAware
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
@@ -29,10 +32,24 @@ class CopyTradingService(
|
|||||||
private val monitorService: CopyTradingMonitorService,
|
private val monitorService: CopyTradingMonitorService,
|
||||||
private val jsonUtils: JsonUtils,
|
private val jsonUtils: JsonUtils,
|
||||||
private val gson: Gson
|
private val gson: Gson
|
||||||
) {
|
) : ApplicationContextAware {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
|
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
|
||||||
|
|
||||||
|
private var applicationContext: ApplicationContext? = null
|
||||||
|
|
||||||
|
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||||
|
this.applicationContext = applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取代理对象,用于解决 @Transactional 自调用问题
|
||||||
|
*/
|
||||||
|
private fun getSelf(): CopyTradingService {
|
||||||
|
return applicationContext?.getBean(CopyTradingService::class.java)
|
||||||
|
?: throw IllegalStateException("ApplicationContext not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建跟单配置
|
* 创建跟单配置
|
||||||
* 支持两种方式:
|
* 支持两种方式:
|
||||||
@@ -86,7 +103,8 @@ class CopyTradingService(
|
|||||||
maxPositionCount = request.maxPositionCount,
|
maxPositionCount = request.maxPositionCount,
|
||||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||||
keywords = convertKeywordsToJson(request.keywords),
|
keywords = convertKeywordsToJson(request.keywords),
|
||||||
maxMarketEndDate = request.maxMarketEndDate
|
maxMarketEndDate = request.maxMarketEndDate,
|
||||||
|
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 手动输入(所有字段必须提供)
|
// 手动输入(所有字段必须提供)
|
||||||
@@ -117,7 +135,8 @@ class CopyTradingService(
|
|||||||
maxPositionCount = request.maxPositionCount,
|
maxPositionCount = request.maxPositionCount,
|
||||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||||
keywords = convertKeywordsToJson(request.keywords),
|
keywords = convertKeywordsToJson(request.keywords),
|
||||||
maxMarketEndDate = request.maxMarketEndDate
|
maxMarketEndDate = request.maxMarketEndDate,
|
||||||
|
pushFilteredOrders = request.pushFilteredOrders ?: false // 手动输入时使用请求中的值,默认为 false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +169,8 @@ class CopyTradingService(
|
|||||||
keywords = config.keywords,
|
keywords = config.keywords,
|
||||||
configName = configName,
|
configName = configName,
|
||||||
pushFailedOrders = request.pushFailedOrders ?: false,
|
pushFailedOrders = request.pushFailedOrders ?: false,
|
||||||
maxMarketEndDate = config.maxMarketEndDate
|
maxMarketEndDate = config.maxMarketEndDate,
|
||||||
|
pushFilteredOrders = config.pushFilteredOrders
|
||||||
)
|
)
|
||||||
|
|
||||||
val saved = copyTradingRepository.save(copyTrading)
|
val saved = copyTradingRepository.save(copyTrading)
|
||||||
@@ -211,12 +231,67 @@ class CopyTradingService(
|
|||||||
websocketReconnectInterval = request.websocketReconnectInterval ?: copyTrading.websocketReconnectInterval,
|
websocketReconnectInterval = request.websocketReconnectInterval ?: copyTrading.websocketReconnectInterval,
|
||||||
websocketMaxRetries = request.websocketMaxRetries ?: copyTrading.websocketMaxRetries,
|
websocketMaxRetries = request.websocketMaxRetries ?: copyTrading.websocketMaxRetries,
|
||||||
supportSell = request.supportSell ?: copyTrading.supportSell,
|
supportSell = request.supportSell ?: copyTrading.supportSell,
|
||||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
|
// 处理可选字段:空字符串表示要清空(设置为 null),null 表示不更新,转换失败保留旧值
|
||||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
|
minOrderDepth = if (request.minOrderDepth != null) {
|
||||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice,
|
if (request.minOrderDepth.isBlank()) {
|
||||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
|
null
|
||||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue,
|
} else {
|
||||||
maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount,
|
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,
|
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
|
||||||
keywords = if (request.keywords != null) {
|
keywords = if (request.keywords != null) {
|
||||||
convertKeywordsToJson(request.keywords)
|
convertKeywordsToJson(request.keywords)
|
||||||
@@ -227,7 +302,17 @@ class CopyTradingService(
|
|||||||
},
|
},
|
||||||
configName = configName,
|
configName = configName,
|
||||||
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
|
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()
|
updatedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -262,7 +347,7 @@ class CopyTradingService(
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
|
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
|
||||||
return updateCopyTrading(
|
return getSelf().updateCopyTrading(
|
||||||
CopyTradingUpdateRequest(
|
CopyTradingUpdateRequest(
|
||||||
copyTradingId = request.copyTradingId,
|
copyTradingId = request.copyTradingId,
|
||||||
enabled = request.enabled
|
enabled = request.enabled
|
||||||
@@ -440,6 +525,7 @@ class CopyTradingService(
|
|||||||
keywords = convertJsonToKeywords(copyTrading.keywords),
|
keywords = convertJsonToKeywords(copyTrading.keywords),
|
||||||
configName = copyTrading.configName,
|
configName = copyTrading.configName,
|
||||||
pushFailedOrders = copyTrading.pushFailedOrders,
|
pushFailedOrders = copyTrading.pushFailedOrders,
|
||||||
|
pushFilteredOrders = copyTrading.pushFilteredOrders,
|
||||||
maxMarketEndDate = copyTrading.maxMarketEndDate,
|
maxMarketEndDate = copyTrading.maxMarketEndDate,
|
||||||
createdAt = copyTrading.createdAt,
|
createdAt = copyTrading.createdAt,
|
||||||
updatedAt = copyTrading.updatedAt
|
updatedAt = copyTrading.updatedAt
|
||||||
@@ -502,6 +588,7 @@ class CopyTradingService(
|
|||||||
val maxPositionCount: Int?,
|
val maxPositionCount: Int?,
|
||||||
val keywordFilterMode: String,
|
val keywordFilterMode: String,
|
||||||
val keywords: String?, // JSON 字符串
|
val keywords: String?, // JSON 字符串
|
||||||
val maxMarketEndDate: Long? // 市场截止时间限制(毫秒时间戳)
|
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
|
||||||
|
val pushFilteredOrders: Boolean // 推送已过滤订单(默认关闭)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -114,10 +114,10 @@ class AccountOnChainMonitorService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val receiptRpcResponse = receiptResponse.body()!!
|
val receiptRpcResponse = receiptResponse.body()!!
|
||||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result.isJsonNull) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Gson 解析 receipt JSON
|
// 使用 Gson 解析 receipt JSON
|
||||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -9,8 +9,8 @@ import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
|||||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||||
import jakarta.annotation.PreDestroy
|
import jakarta.annotation.PreDestroy
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -28,8 +28,7 @@ class CopyTradingWebSocketService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
|
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
|
||||||
|
|
||||||
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
|
private val websocketUrl: String = PolymarketConstants.USER_WS_URL
|
||||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
|
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
|
||||||
@@ -214,7 +213,7 @@ class CopyTradingWebSocketService(
|
|||||||
// 处理交易
|
// 处理交易
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
copyOrderTrackingService.processTrade(leaderId, trade, "websocket")
|
copyOrderTrackingService.processTrade(leaderId, trade, "activity-ws")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-27
@@ -1,5 +1,8 @@
|
|||||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine
|
||||||
|
import com.google.gson.JsonNull
|
||||||
import com.wrbug.polymarketbot.api.*
|
import com.wrbug.polymarketbot.api.*
|
||||||
import com.wrbug.polymarketbot.entity.Leader
|
import com.wrbug.polymarketbot.entity.Leader
|
||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
@@ -11,6 +14,7 @@ import okhttp3.OkHttpClient
|
|||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 链上 WebSocket 监听服务
|
* 链上 WebSocket 监听服务
|
||||||
@@ -23,12 +27,17 @@ class OnChainWsService(
|
|||||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||||
private val leaderRepository: LeaderRepository
|
private val leaderRepository: LeaderRepository
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
|
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
|
||||||
|
|
||||||
// 存储需要监听的Leader:leaderId -> Leader
|
// 存储需要监听的Leader:leaderId -> Leader
|
||||||
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
||||||
|
|
||||||
|
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
|
||||||
|
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
|
||||||
|
.maximumSize(100)
|
||||||
|
.build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动链上 WebSocket 监听
|
* 启动链上 WebSocket 监听
|
||||||
* 通过统一服务订阅所有 Leader
|
* 通过统一服务订阅所有 Leader
|
||||||
@@ -40,14 +49,14 @@ class OnChainWsService(
|
|||||||
stop()
|
stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 Leader 列表
|
// 更新 Leader 列表
|
||||||
monitoredLeaders.clear()
|
monitoredLeaders.clear()
|
||||||
leaders.forEach { leader ->
|
leaders.forEach { leader ->
|
||||||
addLeader(leader)
|
addLeader(leader)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加Leader监听
|
* 添加Leader监听
|
||||||
* 通过统一服务订阅该 Leader 的地址
|
* 通过统一服务订阅该 Leader 的地址
|
||||||
@@ -57,17 +66,17 @@ class OnChainWsService(
|
|||||||
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val leaderId = leader.id!!
|
val leaderId = leader.id!!
|
||||||
|
|
||||||
// 如果已经在监听列表中,不重复添加
|
// 如果已经在监听列表中,不重复添加
|
||||||
if (monitoredLeaders.containsKey(leaderId)) {
|
if (monitoredLeaders.containsKey(leaderId)) {
|
||||||
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
|
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
monitoredLeaders[leaderId] = leader
|
monitoredLeaders[leaderId] = leader
|
||||||
|
|
||||||
// 通过统一服务订阅
|
// 通过统一服务订阅
|
||||||
val subscriptionId = "LEADER_$leaderId"
|
val subscriptionId = "LEADER_$leaderId"
|
||||||
unifiedOnChainWsService.subscribe(
|
unifiedOnChainWsService.subscribe(
|
||||||
@@ -79,40 +88,53 @@ class OnChainWsService(
|
|||||||
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
|
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 Leader 的交易
|
* 处理 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
|
val leader = monitoredLeaders[leaderId] ?: return
|
||||||
|
|
||||||
|
// 根据 txHash 去重(使用原子操作避免竞态条件)
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
|
||||||
|
if (existingTimestamp != null) {
|
||||||
|
logger.debug("交易已处理过,跳过: leaderId=$leaderId, txHash=$txHash, firstProcessedAt=$existingTimestamp")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
|
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取交易 receipt
|
// 获取交易 receipt
|
||||||
val receiptRequest = JsonRpcRequest(
|
val receiptRequest = JsonRpcRequest(
|
||||||
method = "eth_getTransactionReceipt",
|
method = "eth_getTransactionReceipt",
|
||||||
params = listOf(txHash)
|
params = listOf(txHash)
|
||||||
)
|
)
|
||||||
|
|
||||||
val receiptResponse = rpcApi.call(receiptRequest)
|
val receiptResponse = rpcApi.call(receiptRequest)
|
||||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||||
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
|
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val receiptRpcResponse = receiptResponse.body()!!
|
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}")
|
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Gson 解析 receipt JSON
|
// 使用 Gson 解析 receipt JSON
|
||||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||||
|
|
||||||
// 获取区块号和时间戳
|
// 获取区块号和时间戳
|
||||||
val blockNumber = receiptJson.get("blockNumber")?.asString
|
val blockNumber = receiptJson.get("blockNumber")?.asString
|
||||||
val blockTimestamp = if (blockNumber != null) {
|
val blockTimestamp = if (blockNumber != null) {
|
||||||
@@ -120,7 +142,7 @@ class OnChainWsService(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 receipt 中的 Transfer 日志
|
// 解析 receipt 中的 Transfer 日志
|
||||||
val logs = receiptJson.getAsJsonArray("logs") ?: run {
|
val logs = receiptJson.getAsJsonArray("logs") ?: run {
|
||||||
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
|
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
|
||||||
@@ -128,7 +150,7 @@ class OnChainWsService(
|
|||||||
}
|
}
|
||||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||||
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
||||||
|
|
||||||
// 解析交易信息
|
// 解析交易信息
|
||||||
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||||
txHash = txHash,
|
txHash = txHash,
|
||||||
@@ -138,7 +160,7 @@ class OnChainWsService(
|
|||||||
erc1155Transfers = erc1155Transfers,
|
erc1155Transfers = erc1155Transfers,
|
||||||
retrofitFactory = retrofitFactory
|
retrofitFactory = retrofitFactory
|
||||||
)
|
)
|
||||||
|
|
||||||
if (trade != null) {
|
if (trade != null) {
|
||||||
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
|
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
|
||||||
// 调用 processTrade 处理交易
|
// 调用 processTrade 处理交易
|
||||||
@@ -154,21 +176,21 @@ class OnChainWsService(
|
|||||||
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除Leader监听
|
* 移除Leader监听
|
||||||
* 取消该 Leader 的订阅
|
* 取消该 Leader 的订阅
|
||||||
*/
|
*/
|
||||||
fun removeLeader(leaderId: Long) {
|
fun removeLeader(leaderId: Long) {
|
||||||
monitoredLeaders.remove(leaderId)
|
monitoredLeaders.remove(leaderId)
|
||||||
|
|
||||||
// 通过统一服务取消订阅
|
// 通过统一服务取消订阅
|
||||||
val subscriptionId = "LEADER_$leaderId"
|
val subscriptionId = "LEADER_$leaderId"
|
||||||
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
||||||
|
|
||||||
logger.info("移除 Leader 监听: leaderId=$leaderId")
|
logger.info("移除 Leader 监听: leaderId=$leaderId")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 停止监听
|
* 停止监听
|
||||||
*/
|
*/
|
||||||
@@ -180,7 +202,7 @@ class OnChainWsService(
|
|||||||
}
|
}
|
||||||
monitoredLeaders.clear()
|
monitoredLeaders.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
stop()
|
stop()
|
||||||
|
|||||||
+181
-17
@@ -1,5 +1,7 @@
|
|||||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine
|
||||||
import com.wrbug.polymarketbot.api.TradeResponse
|
import com.wrbug.polymarketbot.api.TradeResponse
|
||||||
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
||||||
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
||||||
@@ -7,18 +9,19 @@ import com.wrbug.polymarketbot.entity.Leader
|
|||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||||
import com.wrbug.polymarketbot.util.fromJson
|
import com.wrbug.polymarketbot.util.fromJson
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||||
import jakarta.annotation.PreDestroy
|
import jakarta.annotation.PreDestroy
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Polymarket Activity WebSocket 监听服务
|
* Polymarket Activity WebSocket 监听服务
|
||||||
* 通过订阅全局 activity 交易流,客户端过滤 Leader 地址,实现实时交易检测
|
* 通过订阅全局 activity 交易流(trades + orders_matched),客户端过滤 Leader 地址,实现实时交易检测
|
||||||
* 延迟 < 100ms,适合快速跟单场景
|
* 延迟 < 100ms,适合快速跟单场景
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -29,8 +32,7 @@ class PolymarketActivityWsService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
|
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
|
||||||
|
|
||||||
@Value("\${polymarket.websocket.activity.url:wss://ws-live-data.polymarket.com}")
|
private val websocketUrl: String = PolymarketConstants.ACTIVITY_WS_URL
|
||||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
@@ -40,10 +42,30 @@ class PolymarketActivityWsService(
|
|||||||
// 要监听的 Leader 地址集合(小写地址 -> leaderId)
|
// 要监听的 Leader 地址集合(小写地址 -> leaderId)
|
||||||
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
|
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
|
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
|
||||||
|
// 因为同时订阅 trades 和 orders_matched,同一个交易可能被推送两次
|
||||||
|
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
|
||||||
|
.maximumSize(100)
|
||||||
|
.expireAfterWrite(10, TimeUnit.MINUTES)
|
||||||
|
.build()
|
||||||
|
|
||||||
// 是否已订阅
|
// 是否已订阅
|
||||||
@Volatile
|
@Volatile
|
||||||
private var isSubscribed = false
|
private var isSubscribed = false
|
||||||
|
|
||||||
|
// 最后一次收到 activity 消息的时间(毫秒时间戳)
|
||||||
|
@Volatile
|
||||||
|
private var lastActivityTime: Long = 0
|
||||||
|
|
||||||
|
// Activity 消息超时检测任务
|
||||||
|
private var activityTimeoutJob: Job? = null
|
||||||
|
|
||||||
|
// 性能统计
|
||||||
|
private var totalMessagesProcessed = 0L
|
||||||
|
private var addressMatchMessages = 0L
|
||||||
|
private var jsonParseMessages = 0L
|
||||||
|
private var duplicateTxHashMessages = 0L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动监听
|
* 启动监听
|
||||||
*/
|
*/
|
||||||
@@ -62,7 +84,7 @@ class PolymarketActivityWsService(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("启动 Activity WebSocket 监听,监控 ${monitoredAddresses.size} 个 Leader 地址")
|
logger.info("启动 Activity WebSocket 监听(trades + orders_matched),监控 ${monitoredAddresses.size} 个 Leader 地址")
|
||||||
connectAndSubscribe()
|
connectAndSubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +181,7 @@ class PolymarketActivityWsService(
|
|||||||
* 订阅全局 activity
|
* 订阅全局 activity
|
||||||
* 根据 @polymarket/real-time-data-client 的协议格式
|
* 根据 @polymarket/real-time-data-client 的协议格式
|
||||||
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
|
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
|
||||||
|
* 同时订阅 trades 和 orders_matched 两种类型
|
||||||
*/
|
*/
|
||||||
private fun subscribeAllActivity() {
|
private fun subscribeAllActivity() {
|
||||||
val client = wsClient
|
val client = wsClient
|
||||||
@@ -170,6 +193,7 @@ class PolymarketActivityWsService(
|
|||||||
try {
|
try {
|
||||||
// 根据 real-time-data-client 的协议格式
|
// 根据 real-time-data-client 的协议格式
|
||||||
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
|
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
|
||||||
|
// 同时订阅 trades 和 orders_matched 两种类型
|
||||||
val subscribeMessage = """
|
val subscribeMessage = """
|
||||||
{
|
{
|
||||||
"action": "subscribe",
|
"action": "subscribe",
|
||||||
@@ -177,6 +201,10 @@ class PolymarketActivityWsService(
|
|||||||
{
|
{
|
||||||
"topic": "activity",
|
"topic": "activity",
|
||||||
"type": "trades"
|
"type": "trades"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"topic": "activity",
|
||||||
|
"type": "orders_matched"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -184,46 +212,154 @@ class PolymarketActivityWsService(
|
|||||||
|
|
||||||
client.sendMessage(subscribeMessage)
|
client.sendMessage(subscribeMessage)
|
||||||
isSubscribed = true
|
isSubscribed = true
|
||||||
logger.info("Activity WebSocket 订阅成功(全局交易流)")
|
// 重置最后一次收到 activity 消息的时间
|
||||||
|
lastActivityTime = System.currentTimeMillis()
|
||||||
|
// 启动 Activity 消息超时检测
|
||||||
|
// startActivityTimeoutCheck()
|
||||||
|
logger.info("Activity WebSocket 订阅成功(全局交易流: trades + orders_matched)")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("订阅 Activity WebSocket 失败", e)
|
logger.error("订阅 Activity WebSocket 失败", e)
|
||||||
isSubscribed = false
|
isSubscribed = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查消息是否包含监听的 Leader 地址
|
||||||
|
* 快速过滤,避免不必要的 JSON 解析
|
||||||
|
* 只需要检查 "proxyWallet":"0x..." 或 "trader":{"address":"0x..."} 格式
|
||||||
|
*/
|
||||||
|
private fun containsMonitoredAddress(message: String): Boolean {
|
||||||
|
// 快速检查:如果消息很短,不可能包含地址
|
||||||
|
if (message.length < 50) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历所有监听的地址
|
||||||
|
for ((address, leaderId) in monitoredAddresses) {
|
||||||
|
// 检查 proxyWallet:格式为 "proxyWallet":"0x..."
|
||||||
|
if (message.contains("\"proxyWallet\":\"$address\"", ignoreCase = true)) {
|
||||||
|
addressMatchMessages++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 trader.address:格式为 "trader":{"address":"0x..."}
|
||||||
|
if (message.contains("\"trader\"", ignoreCase = true) &&
|
||||||
|
message.contains("\"address\":\"$address\"", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
addressMatchMessages++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理消息
|
* 处理消息
|
||||||
*/
|
*/
|
||||||
private fun handleMessage(message: String) {
|
private fun handleMessage(message: String) {
|
||||||
try {
|
try {
|
||||||
|
totalMessagesProcessed++
|
||||||
|
|
||||||
// 处理 PONG 响应
|
// 处理 PONG 响应
|
||||||
if (message.trim() == "PONG" || message.trim() == "pong") {
|
if (message.trim() == "PONG" || message.trim() == "pong") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用扩展函数解析消息
|
// 快速预检查:检查是否包含监听地址
|
||||||
|
// 绝大部分消息会在这一步被过滤掉,避免不必要的 JSON 解析
|
||||||
|
if (!containsMonitoredAddress(message)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.info("发现leader交易:${message}")
|
||||||
|
// 使用扩展函数解析消息(只对包含监听地址的消息)
|
||||||
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
||||||
// 不是有效的 JSON 或格式不匹配,跳过
|
// 不是有效的 JSON 或格式不匹配,跳过
|
||||||
logger.warn("无法解析为 ActivityTradeMessage,可能不是 activity 消息: ${message.take(200)}")
|
logger.warn("无法解析为 ActivityTradeMessage: ${message.take(200)}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否是 activity trade 消息
|
jsonParseMessages++
|
||||||
if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") {
|
|
||||||
// 不是我们关心的消息,直接返回
|
// 检查是否是 activity 消息(trades 或 orders_matched)
|
||||||
|
if (tradeMessage.topic != "activity" ||
|
||||||
|
(tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新最后一次收到 activity 消息的时间(即使不是我们监听的 Leader 的交易)
|
||||||
|
lastActivityTime = System.currentTimeMillis()
|
||||||
|
|
||||||
val payload = tradeMessage.payload
|
val payload = tradeMessage.payload
|
||||||
|
|
||||||
|
// 根据 txHash 去重(使用原子操作避免竞态条件)
|
||||||
|
val txHash = payload.transactionHash
|
||||||
|
if (txHash != null && txHash.isNotBlank()) {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
|
||||||
|
if (existingTimestamp != null) {
|
||||||
|
duplicateTxHashMessages++
|
||||||
|
logger.debug("交易已处理过,跳过: txHash=$txHash, firstProcessedAt=$existingTimestamp, type=${tradeMessage.type}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 提取交易者地址
|
// 提取交易者地址
|
||||||
val traderAddress = extractTraderAddress(payload) ?: run {
|
val traderAddress = extractTraderAddress(payload) ?: run {
|
||||||
// 没有交易者地址,跳过
|
// 没有交易者地址,跳过
|
||||||
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
|
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否是我们监听的 Leader
|
// 二次验证:确认地址匹配
|
||||||
val normalizedAddress = traderAddress.lowercase()
|
val normalizedAddress = traderAddress.lowercase()
|
||||||
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
|
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
|
||||||
return
|
return
|
||||||
@@ -383,10 +519,13 @@ class PolymarketActivityWsService(
|
|||||||
*/
|
*/
|
||||||
fun stop() {
|
fun stop() {
|
||||||
logger.info("停止 Activity WebSocket 监听")
|
logger.info("停止 Activity WebSocket 监听")
|
||||||
|
stopActivityTimeoutCheck()
|
||||||
wsClient?.closeConnection()
|
wsClient?.closeConnection()
|
||||||
wsClient = null
|
wsClient = null
|
||||||
isSubscribed = false
|
isSubscribed = false
|
||||||
monitoredAddresses.clear()
|
monitoredAddresses.clear()
|
||||||
|
processedTxHashes.invalidateAll() // 清空去重缓存
|
||||||
|
lastActivityTime = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -403,8 +542,33 @@ class PolymarketActivityWsService(
|
|||||||
return monitoredAddresses.size
|
return monitoredAddresses.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取性能统计信息
|
||||||
|
*/
|
||||||
|
fun getPerformanceStats(): Map<String, Any> {
|
||||||
|
val jsonParseRate = if (totalMessagesProcessed > 0) {
|
||||||
|
(jsonParseMessages.toDouble() / totalMessagesProcessed * 100).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
"totalMessages" to totalMessagesProcessed,
|
||||||
|
"addressMatches" to addressMatchMessages,
|
||||||
|
"jsonParses" to jsonParseMessages,
|
||||||
|
"duplicateTxHashes" to duplicateTxHashMessages,
|
||||||
|
"jsonParseRate" to "$jsonParseRate%",
|
||||||
|
"filteringEfficiency" to if (totalMessagesProcessed > 0) {
|
||||||
|
((1.0 - jsonParseMessages.toDouble() / totalMessagesProcessed) * 100).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
|
logger.info("Activity WS 性能统计: ${getPerformanceStats()}")
|
||||||
stop()
|
stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -129,12 +129,20 @@ class UnifiedOnChainWsService(
|
|||||||
}
|
}
|
||||||
addressConnections.clear()
|
addressConnections.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取连接状态
|
||||||
|
* @return Map<address, isConnected>
|
||||||
|
*/
|
||||||
|
fun getConnectionStatuses(): Map<String, Boolean> {
|
||||||
|
return addressConnections.mapValues { (_, connection) -> connection.isConnected() }
|
||||||
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
fun init() {
|
fun init() {
|
||||||
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
|
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
stop()
|
stop()
|
||||||
@@ -211,6 +219,10 @@ class UnifiedOnChainWsService(
|
|||||||
return subscriptions.isEmpty()
|
return subscriptions.isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isConnected(): Boolean {
|
||||||
|
return isConnected
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun startConnectionLoop() {
|
private suspend fun startConnectionLoop() {
|
||||||
while (scope.isActive) {
|
while (scope.isActive) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+2
-2
@@ -18,6 +18,7 @@ import com.wrbug.polymarketbot.util.CryptoUtils
|
|||||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.service.common.MarketService
|
import com.wrbug.polymarketbot.service.common.MarketService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -41,8 +42,7 @@ class OrderPushService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
|
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
|
||||||
|
|
||||||
@Value("\${polymarket.rtds.ws-url}")
|
private val polymarketWsUrl: String = PolymarketConstants.RTDS_WS_URL
|
||||||
private lateinit var polymarketWsUrl: String
|
|
||||||
|
|
||||||
// 存储账户 ID 和对应的 WebSocket 连接
|
// 存储账户 ID 和对应的 WebSocket 连接
|
||||||
private val accountConnections = ConcurrentHashMap<Long, PolymarketWebSocketClient>()
|
private val accountConnections = ConcurrentHashMap<Long, PolymarketWebSocketClient>()
|
||||||
|
|||||||
+56
-35
@@ -23,6 +23,8 @@ import com.wrbug.polymarketbot.service.common.MarketService
|
|||||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||||
|
import org.springframework.context.ApplicationContext
|
||||||
|
import org.springframework.context.ApplicationContextAware
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
@@ -51,12 +53,26 @@ open class CopyOrderTrackingService(
|
|||||||
private val cryptoUtils: CryptoUtils,
|
private val cryptoUtils: CryptoUtils,
|
||||||
private val marketService: MarketService, // 市场信息服务
|
private val marketService: MarketService, // 市场信息服务
|
||||||
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
||||||
) {
|
) : ApplicationContextAware {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
|
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
|
||||||
|
|
||||||
// 协程作用域(用于异步发送通知)
|
// 协程作用域(用于异步发送通知)
|
||||||
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
private var applicationContext: ApplicationContext? = null
|
||||||
|
|
||||||
|
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||||
|
this.applicationContext = applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取代理对象,用于解决 @Transactional 自调用问题
|
||||||
|
*/
|
||||||
|
private fun getSelf(): CopyOrderTrackingService {
|
||||||
|
return applicationContext?.getBean(CopyOrderTrackingService::class.java)
|
||||||
|
?: throw IllegalStateException("ApplicationContext not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 Mutex 保证线程安全(按交易ID锁定)
|
// 使用 Mutex 保证线程安全(按交易ID锁定)
|
||||||
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||||
@@ -138,10 +154,11 @@ open class CopyOrderTrackingService(
|
|||||||
return@withLock Result.success(Unit)
|
return@withLock Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 处理交易逻辑
|
// 2. 处理交易逻辑(通过代理对象调用,确保 @Transactional 生效)
|
||||||
|
val self = getSelf()
|
||||||
val result = when (trade.side.uppercase()) {
|
val result = when (trade.side.uppercase()) {
|
||||||
"BUY" -> processBuyTrade(leaderId, trade)
|
"BUY" -> self.processBuyTrade(leaderId, trade, source)
|
||||||
"SELL" -> processSellTrade(leaderId, trade)
|
"SELL" -> self.processSellTrade(leaderId, trade)
|
||||||
else -> {
|
else -> {
|
||||||
logger.warn("未知的交易方向: ${trade.side}")
|
logger.warn("未知的交易方向: ${trade.side}")
|
||||||
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||||
@@ -213,7 +230,7 @@ open class CopyOrderTrackingService(
|
|||||||
* 创建跟单买入订单并记录到跟踪表
|
* 创建跟单买入订单并记录到跟踪表
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse): Result<Unit> {
|
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
|
||||||
return try {
|
return try {
|
||||||
// 1. 查找所有启用且支持该Leader的跟单关系
|
// 1. 查找所有启用且支持该Leader的跟单关系
|
||||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||||
@@ -285,7 +302,7 @@ open class CopyOrderTrackingService(
|
|||||||
|
|
||||||
// 过滤条件检查(在计算订单参数之前)
|
// 过滤条件检查(在计算订单参数之前)
|
||||||
// 传入 Leader 交易价格,用于价格区间检查
|
// 传入 Leader 交易价格,用于价格区间检查
|
||||||
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
|
// 传入跟单金额和市场ID,用于仓位检查(按市场+方向检查仓位)
|
||||||
// 传入市场标题,用于关键字过滤
|
// 传入市场标题,用于关键字过滤
|
||||||
// 传入市场截止时间,用于市场截止时间检查
|
// 传入市场截止时间,用于市场截止时间检查
|
||||||
// 订单簿只请求一次,返回给后续逻辑使用
|
// 订单簿只请求一次,返回给后续逻辑使用
|
||||||
@@ -296,7 +313,8 @@ open class CopyOrderTrackingService(
|
|||||||
copyOrderAmount = copyOrderAmount,
|
copyOrderAmount = copyOrderAmount,
|
||||||
marketId = trade.market,
|
marketId = trade.market,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketEndDate = marketEndDate
|
marketEndDate = marketEndDate,
|
||||||
|
outcomeIndex = trade.outcomeIndex
|
||||||
)
|
)
|
||||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||||
if (!filterResult.isPassed) {
|
if (!filterResult.isPassed) {
|
||||||
@@ -347,27 +365,29 @@ open class CopyOrderTrackingService(
|
|||||||
logger.error("保存被过滤订单失败: ${e.message}", e)
|
logger.error("保存被过滤订单失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送 Telegram 通知
|
// 发送 Telegram 通知(仅在 pushFilteredOrders 为 true 时发送)
|
||||||
val locale = try {
|
if (copyTrading.pushFilteredOrders) {
|
||||||
org.springframework.context.i18n.LocaleContextHolder.getLocale()
|
val locale = try {
|
||||||
} catch (e: Exception) {
|
org.springframework.context.i18n.LocaleContextHolder.getLocale()
|
||||||
java.util.Locale("zh", "CN") // 默认简体中文
|
} catch (e: Exception) {
|
||||||
}
|
java.util.Locale("zh", "CN") // 默认简体中文
|
||||||
|
}
|
||||||
|
|
||||||
telegramNotificationService?.sendOrderFilteredNotification(
|
telegramNotificationService?.sendOrderFilteredNotification(
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketId = trade.market,
|
marketId = trade.market,
|
||||||
marketSlug = marketSlug,
|
marketSlug = marketSlug,
|
||||||
side = "BUY",
|
side = "BUY",
|
||||||
outcome = trade.outcome,
|
outcome = trade.outcome,
|
||||||
price = trade.price,
|
price = trade.price,
|
||||||
size = trade.size,
|
size = trade.size,
|
||||||
filterReason = filterResult.reason,
|
filterReason = filterResult.reason,
|
||||||
filterType = filterType,
|
filterType = filterType,
|
||||||
accountName = account.accountName,
|
accountName = account.accountName,
|
||||||
walletAddress = account.walletAddress,
|
walletAddress = account.walletAddress,
|
||||||
locale = locale
|
locale = locale
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理被过滤订单通知失败: ${e.message}", e)
|
logger.error("处理被过滤订单通知失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -620,7 +640,8 @@ open class CopyOrderTrackingService(
|
|||||||
price = buyPrice, // 使用下单价格,临时值
|
price = buyPrice, // 使用下单价格,临时值
|
||||||
remainingQuantity = finalBuyQuantity,
|
remainingQuantity = finalBuyQuantity,
|
||||||
status = "filled",
|
status = "filled",
|
||||||
notificationSent = false // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
notificationSent = false, // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
||||||
|
source = source // 订单来源
|
||||||
)
|
)
|
||||||
|
|
||||||
copyOrderTrackingRepository.save(tracking)
|
copyOrderTrackingRepository.save(tracking)
|
||||||
@@ -683,8 +704,8 @@ open class CopyOrderTrackingService(
|
|||||||
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
||||||
return when (copyTrading.copyMode) {
|
return when (copyTrading.copyMode) {
|
||||||
"RATIO" -> {
|
"RATIO" -> {
|
||||||
// 比例模式:Leader 数量 × (比例 / 100)
|
// 比例模式:Leader 数量 × 比例倍数(copyRatio 已经是倍数值,如 1.3 表示 130%)
|
||||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
"FIXED" -> {
|
"FIXED" -> {
|
||||||
@@ -716,7 +737,7 @@ open class CopyOrderTrackingService(
|
|||||||
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
||||||
?: run {
|
?: run {
|
||||||
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
||||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
||||||
@@ -787,7 +808,7 @@ open class CopyOrderTrackingService(
|
|||||||
// 如果无法计算总比例(查询失败),使用默认比例
|
// 如果无法计算总比例(查询失败),使用默认比例
|
||||||
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
||||||
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
||||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
||||||
@@ -863,13 +884,13 @@ open class CopyOrderTrackingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
"RATIO" -> {
|
"RATIO" -> {
|
||||||
// 比例模式:直接使用配置的 copyRatio (需要除以100)
|
// 比例模式:直接使用配置的 copyRatio(已经是倍数值,如 1.3 表示 130%)
|
||||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
||||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+129
-243
@@ -31,8 +31,6 @@ class CopyTradingStatisticsService(
|
|||||||
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
||||||
private val accountRepository: AccountRepository,
|
private val accountRepository: AccountRepository,
|
||||||
private val leaderRepository: LeaderRepository,
|
private val leaderRepository: LeaderRepository,
|
||||||
private val accountService: AccountService,
|
|
||||||
private val blockchainService: BlockchainService,
|
|
||||||
private val marketService: com.wrbug.polymarketbot.service.common.MarketService
|
private val marketService: com.wrbug.polymarketbot.service.common.MarketService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -63,19 +61,12 @@ class CopyTradingStatisticsService(
|
|||||||
// 6. 计算统计信息
|
// 6. 计算统计信息
|
||||||
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails)
|
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails)
|
||||||
|
|
||||||
// 7. 获取链上实际持仓(用于准确计算未实现盈亏,考虑手动卖出的情况)
|
// 7. 不再计算未实现盈亏和持仓价值(优化性能)
|
||||||
val actualPositions = getActualPositions(account)
|
// 未实现盈亏计算需要查询链上持仓和市场价格,性能开销大
|
||||||
|
val unrealizedPnl = "0"
|
||||||
|
val positionValue = "0"
|
||||||
|
|
||||||
// 8. 获取当前市场价格(用于计算未实现盈亏)
|
// 8. 构建响应(总盈亏 = 已实现盈亏)
|
||||||
val currentPrice = getCurrentMarketPrice(buyOrders)
|
|
||||||
|
|
||||||
// 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity)
|
|
||||||
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions)
|
|
||||||
|
|
||||||
// 10. 计算持仓价值(使用链上实际持仓和当前价格)
|
|
||||||
val positionValue = calculatePositionValue(buyOrders, currentPrice, actualPositions)
|
|
||||||
|
|
||||||
// 11. 构建响应
|
|
||||||
val response = CopyTradingStatisticsResponse(
|
val response = CopyTradingStatisticsResponse(
|
||||||
copyTradingId = copyTradingId,
|
copyTradingId = copyTradingId,
|
||||||
accountId = copyTrading.accountId,
|
accountId = copyTrading.accountId,
|
||||||
@@ -94,8 +85,8 @@ class CopyTradingStatisticsService(
|
|||||||
currentPositionValue = positionValue,
|
currentPositionValue = positionValue,
|
||||||
totalRealizedPnl = statistics.totalRealizedPnl,
|
totalRealizedPnl = statistics.totalRealizedPnl,
|
||||||
totalUnrealizedPnl = unrealizedPnl,
|
totalUnrealizedPnl = unrealizedPnl,
|
||||||
totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(),
|
totalPnl = statistics.totalRealizedPnl,
|
||||||
totalPnlPercent = calculatePnlPercent(statistics.totalBuyAmount, statistics.totalRealizedPnl, unrealizedPnl)
|
totalPnlPercent = calculatePnlPercentOnlyRealized(statistics.totalBuyAmount, statistics.totalRealizedPnl)
|
||||||
)
|
)
|
||||||
|
|
||||||
Result.success(response)
|
Result.success(response)
|
||||||
@@ -143,12 +134,21 @@ class CopyTradingStatisticsService(
|
|||||||
private fun getBuyOrderList(request: OrderTrackingRequest): Pair<List<BuyOrderInfo>, Long> {
|
private fun getBuyOrderList(request: OrderTrackingRequest): Pair<List<BuyOrderInfo>, Long> {
|
||||||
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
|
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
|
||||||
|
|
||||||
|
// 批量获取市场信息(用于筛选)
|
||||||
|
val allMarketIds = orders.map { it.marketId }.distinct()
|
||||||
|
val markets = marketService.getMarkets(allMarketIds)
|
||||||
|
|
||||||
// 筛选
|
// 筛选
|
||||||
if (!request.marketId.isNullOrBlank()) {
|
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()) {
|
if (!request.marketTitle.isNullOrBlank()) {
|
||||||
orders = orders.filter { it.side == request.side }
|
// marketTitle 关键字筛选
|
||||||
|
orders = orders.filter { order ->
|
||||||
|
val market = markets[order.marketId]
|
||||||
|
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!request.status.isNullOrBlank()) {
|
if (!request.status.isNullOrBlank()) {
|
||||||
orders = orders.filter { it.status == request.status }
|
orders = orders.filter { it.status == request.status }
|
||||||
@@ -166,10 +166,6 @@ class CopyTradingStatisticsService(
|
|||||||
val end = minOf(start + limit, orders.size)
|
val end = minOf(start + limit, orders.size)
|
||||||
val pagedOrders = if (start < orders.size) orders.subList(start, end) else emptyList()
|
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
|
// 转换为DTO
|
||||||
val list = pagedOrders.map { order ->
|
val list = pagedOrders.map { order ->
|
||||||
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
||||||
@@ -202,12 +198,21 @@ class CopyTradingStatisticsService(
|
|||||||
private fun getSellOrderList(request: OrderTrackingRequest): Pair<List<SellOrderInfo>, Long> {
|
private fun getSellOrderList(request: OrderTrackingRequest): Pair<List<SellOrderInfo>, Long> {
|
||||||
var records = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
|
var records = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
|
||||||
|
|
||||||
|
// 批量获取市场信息(用于筛选)
|
||||||
|
val allMarketIds = records.map { it.marketId }.distinct()
|
||||||
|
val markets = marketService.getMarkets(allMarketIds)
|
||||||
|
|
||||||
// 筛选
|
// 筛选
|
||||||
if (!request.marketId.isNullOrBlank()) {
|
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()) {
|
if (!request.marketTitle.isNullOrBlank()) {
|
||||||
records = records.filter { it.side == request.side }
|
// marketTitle 关键字筛选
|
||||||
|
records = records.filter { record ->
|
||||||
|
val market = markets[record.marketId]
|
||||||
|
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val total = records.size.toLong()
|
val total = records.size.toLong()
|
||||||
@@ -222,10 +227,6 @@ class CopyTradingStatisticsService(
|
|||||||
val end = minOf(start + limit, records.size)
|
val end = minOf(start + limit, records.size)
|
||||||
val pagedRecords = if (start < records.size) records.subList(start, end) else emptyList()
|
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
|
// 转换为DTO
|
||||||
val list = pagedRecords.map { record ->
|
val list = pagedRecords.map { record ->
|
||||||
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
|
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
|
||||||
@@ -256,6 +257,14 @@ class CopyTradingStatisticsService(
|
|||||||
private fun getMatchedOrderList(request: OrderTrackingRequest): Pair<List<MatchedOrderInfo>, Long> {
|
private fun getMatchedOrderList(request: OrderTrackingRequest): Pair<List<MatchedOrderInfo>, Long> {
|
||||||
val matchDetails = sellMatchDetailRepository.findByCopyTradingId(request.copyTradingId)
|
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
|
var filtered = matchDetails
|
||||||
if (!request.sellOrderId.isNullOrBlank()) {
|
if (!request.sellOrderId.isNullOrBlank()) {
|
||||||
@@ -269,6 +278,21 @@ class CopyTradingStatisticsService(
|
|||||||
if (!request.buyOrderId.isNullOrBlank()) {
|
if (!request.buyOrderId.isNullOrBlank()) {
|
||||||
filtered = filtered.filter { it.buyOrderId == request.buyOrderId }
|
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()
|
val total = filtered.size.toLong()
|
||||||
|
|
||||||
@@ -283,17 +307,17 @@ class CopyTradingStatisticsService(
|
|||||||
val pagedDetails = if (start < filtered.size) filtered.subList(start, end) else emptyList()
|
val pagedDetails = if (start < filtered.size) filtered.subList(start, end) else emptyList()
|
||||||
|
|
||||||
// 获取匹配记录以获取市场ID
|
// 获取匹配记录以获取市场ID
|
||||||
val matchRecordIds = pagedDetails.map { it.matchRecordId }.distinct()
|
val pagedMatchRecordIds = pagedDetails.map { it.matchRecordId }.distinct()
|
||||||
val matchRecords = matchRecordIds.mapNotNull { id ->
|
val pagedMatchRecords = pagedMatchRecordIds.mapNotNull { id ->
|
||||||
sellMatchRecordRepository.findById(id).orElse(null)
|
sellMatchRecordRepository.findById(id).orElse(null)
|
||||||
}
|
}
|
||||||
val marketIds = matchRecords.map { it.marketId }.distinct()
|
val pagedMarketIds = pagedMatchRecords.map { it.marketId }.distinct()
|
||||||
val markets = marketService.getMarkets(marketIds)
|
val pagedMarkets = marketService.getMarkets(pagedMarketIds)
|
||||||
|
|
||||||
// 转换为DTO
|
// 转换为DTO
|
||||||
val list = pagedDetails.map { detail ->
|
val list = pagedDetails.map { detail ->
|
||||||
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
|
val matchRecord = pagedMatchRecords.find { it.id == detail.matchRecordId }
|
||||||
val market = matchRecord?.let { markets[it.marketId] }
|
val market = matchRecord?.let { pagedMarkets[it.marketId] }
|
||||||
MatchedOrderInfo(
|
MatchedOrderInfo(
|
||||||
sellOrderId = matchRecord?.sellOrderId ?: "",
|
sellOrderId = matchRecord?.sellOrderId ?: "",
|
||||||
buyOrderId = detail.buyOrderId,
|
buyOrderId = detail.buyOrderId,
|
||||||
@@ -358,200 +382,16 @@ class CopyTradingStatisticsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前市场价格
|
* 计算盈亏百分比(仅基于已实现盈亏)
|
||||||
* 按 (marketId, outcomeIndex) 组合获取价格,支持多元市场
|
|
||||||
*/
|
*/
|
||||||
private suspend fun getCurrentMarketPrice(buyOrders: List<CopyOrderTracking>): Map<String, String> {
|
private fun calculatePnlPercentOnlyRealized(
|
||||||
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(
|
|
||||||
totalBuyAmount: String,
|
totalBuyAmount: String,
|
||||||
totalRealizedPnl: String,
|
totalRealizedPnl: String
|
||||||
totalUnrealizedPnl: String
|
|
||||||
): String {
|
): String {
|
||||||
val buyAmount = totalBuyAmount.toSafeBigDecimal()
|
val buyAmount = totalBuyAmount.toSafeBigDecimal()
|
||||||
if (buyAmount.lte(BigDecimal.ZERO)) return "0"
|
if (buyAmount.lte(BigDecimal.ZERO)) return "0"
|
||||||
|
|
||||||
val totalPnl = totalRealizedPnl.toSafeBigDecimal().add(totalUnrealizedPnl.toSafeBigDecimal())
|
val percent = totalRealizedPnl.toSafeBigDecimal().div(buyAmount).multi(100)
|
||||||
val percent = totalPnl.div(buyAmount).multi(100)
|
|
||||||
|
|
||||||
return percent.setScale(2, RoundingMode.HALF_UP).toString()
|
return percent.setScale(2, RoundingMode.HALF_UP).toString()
|
||||||
}
|
}
|
||||||
@@ -743,7 +583,23 @@ class CopyTradingStatisticsService(
|
|||||||
// 2. 获取所有买入订单
|
// 2. 获取所有买入订单
|
||||||
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
|
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>>()
|
val groups = mutableMapOf<String, MutableList<CopyOrderTracking>>()
|
||||||
orders.forEach { order ->
|
orders.forEach { order ->
|
||||||
val marketId = order.marketId
|
val marketId = order.marketId
|
||||||
@@ -755,23 +611,22 @@ class CopyTradingStatisticsService(
|
|||||||
|
|
||||||
// 4. 转换为分组数据并计算统计信息
|
// 4. 转换为分组数据并计算统计信息
|
||||||
val marketIds = groups.keys.toList()
|
val marketIds = groups.keys.toList()
|
||||||
val markets = marketService.getMarkets(marketIds)
|
|
||||||
|
|
||||||
val list = marketIds.map { marketId ->
|
val list = marketIds.map { marketId ->
|
||||||
val marketOrders = groups[marketId] ?: mutableListOf()
|
val marketOrders = groups[marketId] ?: mutableListOf()
|
||||||
|
|
||||||
// 计算统计信息
|
// 计算统计信息
|
||||||
val count = marketOrders.size.toLong()
|
val count = marketOrders.size.toLong()
|
||||||
val totalAmount = marketOrders.sumOf { order ->
|
val totalAmount = marketOrders.sumOf { order ->
|
||||||
order.quantity.toSafeBigDecimal().multi(order.price)
|
order.quantity.toSafeBigDecimal().multi(order.price)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算订单状态统计
|
// 计算订单状态统计
|
||||||
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
|
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
|
||||||
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
|
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
|
||||||
val filledCount = marketOrders.count { it.status == "filled" }
|
val filledCount = marketOrders.count { it.status == "filled" }
|
||||||
val fullyMatched = fullyMatchedCount == marketOrders.size
|
val fullyMatched = fullyMatchedCount == marketOrders.size
|
||||||
|
|
||||||
val stats = MarketOrderStats(
|
val stats = MarketOrderStats(
|
||||||
count = count,
|
count = count,
|
||||||
totalAmount = totalAmount.toString(),
|
totalAmount = totalAmount.toString(),
|
||||||
@@ -781,10 +636,10 @@ class CopyTradingStatisticsService(
|
|||||||
partiallyMatchedCount = partiallyMatchedCount.toLong(),
|
partiallyMatchedCount = partiallyMatchedCount.toLong(),
|
||||||
filledCount = filledCount.toLong()
|
filledCount = filledCount.toLong()
|
||||||
)
|
)
|
||||||
|
|
||||||
// 排序(按创建时间倒序)
|
// 排序(按创建时间倒序)
|
||||||
marketOrders.sortByDescending { it.createdAt }
|
marketOrders.sortByDescending { it.createdAt }
|
||||||
|
|
||||||
// 转换为 DTO
|
// 转换为 DTO
|
||||||
val orderDtos = marketOrders.map { order ->
|
val orderDtos = marketOrders.map { order ->
|
||||||
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
||||||
@@ -807,7 +662,7 @@ class CopyTradingStatisticsService(
|
|||||||
createdAt = order.createdAt
|
createdAt = order.createdAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
MarketOrderGroup(
|
MarketOrderGroup(
|
||||||
marketId = marketId,
|
marketId = marketId,
|
||||||
marketTitle = markets[marketId]?.title,
|
marketTitle = markets[marketId]?.title,
|
||||||
@@ -817,27 +672,35 @@ class CopyTradingStatisticsService(
|
|||||||
stats = stats,
|
stats = stats,
|
||||||
orders = orderDtos as List<Any>
|
orders = orderDtos as List<Any>
|
||||||
)
|
)
|
||||||
}.sortedByDescending { it.stats.count }
|
}.sortedByDescending { group ->
|
||||||
|
// 找出该市场最近的买入订单时间
|
||||||
|
group.orders.mapNotNull { order ->
|
||||||
|
when (order) {
|
||||||
|
is BuyOrderInfo -> order.createdAt
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.maxOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 分页
|
// 5. 分页
|
||||||
val page = (request.page ?: 1)
|
val page = (request.page ?: 1)
|
||||||
val limit = request.limit ?: 20
|
val limit = request.limit ?: 20
|
||||||
val total = list.size.toLong()
|
val total = list.size.toLong()
|
||||||
|
|
||||||
val start = (page - 1) * limit
|
val start = (page - 1) * limit
|
||||||
val end = minOf(start + limit, list.size)
|
val end = minOf(start + limit, list.size)
|
||||||
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
|
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
|
||||||
|
|
||||||
val response = MarketGroupedOrdersResponse(
|
val response = MarketGroupedOrdersResponse(
|
||||||
list = pagedList,
|
list = pagedList,
|
||||||
total = total,
|
total = total,
|
||||||
page = page,
|
page = page,
|
||||||
limit = limit
|
limit = limit
|
||||||
)
|
)
|
||||||
|
|
||||||
Result.success(response)
|
Result.success(response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("获取按市场分组的买入订单列表失败: copyTradingId=${request.copyTradingId}", e)
|
logger.error("获取按市场分组的卖出订单列表失败: copyTradingId=${request.copyTradingId}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,9 +715,25 @@ class CopyTradingStatisticsService(
|
|||||||
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
|
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
|
||||||
|
|
||||||
// 2. 获取所有卖出记录
|
// 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>>()
|
val groups = mutableMapOf<String, MutableList<SellMatchRecord>>()
|
||||||
sellRecords.forEach { record ->
|
sellRecords.forEach { record ->
|
||||||
val marketId = record.marketId
|
val marketId = record.marketId
|
||||||
@@ -866,7 +745,6 @@ class CopyTradingStatisticsService(
|
|||||||
|
|
||||||
// 4. 转换为分组数据并计算统计信息
|
// 4. 转换为分组数据并计算统计信息
|
||||||
val marketIds = groups.keys.toList()
|
val marketIds = groups.keys.toList()
|
||||||
val markets = marketService.getMarkets(marketIds)
|
|
||||||
|
|
||||||
val list = marketIds.map { marketId ->
|
val list = marketIds.map { marketId ->
|
||||||
val marketRecords = groups[marketId] ?: mutableListOf()
|
val marketRecords = groups[marketId] ?: mutableListOf()
|
||||||
@@ -921,7 +799,15 @@ class CopyTradingStatisticsService(
|
|||||||
stats = stats,
|
stats = stats,
|
||||||
orders = orderDtos as List<Any>
|
orders = orderDtos as List<Any>
|
||||||
)
|
)
|
||||||
}.sortedByDescending { it.stats.count }
|
}.sortedByDescending { group ->
|
||||||
|
// 找出该市场最近的卖出订单时间(与买入订单分组排序规则一致)
|
||||||
|
group.orders.mapNotNull { order ->
|
||||||
|
when (order) {
|
||||||
|
is SellOrderInfo -> order.createdAt
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.maxOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 分页
|
// 5. 分页
|
||||||
val page = (request.page ?: 1)
|
val page = (request.page ?: 1)
|
||||||
|
|||||||
+402
-177
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -62,7 +62,8 @@ class CopyTradingTemplateService(
|
|||||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
|
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
|
||||||
maxSpread = request.maxSpread?.toSafeBigDecimal(),
|
maxSpread = request.maxSpread?.toSafeBigDecimal(),
|
||||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
||||||
|
pushFilteredOrders = request.pushFilteredOrders ?: false
|
||||||
)
|
)
|
||||||
|
|
||||||
val saved = templateRepository.save(template)
|
val saved = templateRepository.save(template)
|
||||||
@@ -121,6 +122,7 @@ class CopyTradingTemplateService(
|
|||||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
|
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
|
||||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||||
|
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders,
|
||||||
updatedAt = System.currentTimeMillis()
|
updatedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -186,7 +188,8 @@ class CopyTradingTemplateService(
|
|||||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
|
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
|
||||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
|
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
|
||||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice,
|
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)
|
val saved = templateRepository.save(newTemplate)
|
||||||
@@ -260,6 +263,7 @@ class CopyTradingTemplateService(
|
|||||||
maxSpread = template.maxSpread?.toPlainString(),
|
maxSpread = template.maxSpread?.toPlainString(),
|
||||||
minPrice = template.minPrice?.toPlainString(),
|
minPrice = template.minPrice?.toPlainString(),
|
||||||
maxPrice = template.maxPrice?.toPlainString(),
|
maxPrice = template.maxPrice?.toPlainString(),
|
||||||
|
pushFilteredOrders = template.pushFilteredOrders,
|
||||||
createdAt = template.createdAt,
|
createdAt = template.createdAt,
|
||||||
updatedAt = template.updatedAt
|
updatedAt = template.updatedAt
|
||||||
)
|
)
|
||||||
|
|||||||
+154
-85
@@ -1,5 +1,6 @@
|
|||||||
package com.wrbug.polymarketbot.service.system
|
package com.wrbug.polymarketbot.service.system
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckDto
|
import com.wrbug.polymarketbot.dto.ApiHealthCheckDto
|
||||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
|
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
@@ -11,9 +12,9 @@ import org.slf4j.LoggerFactory
|
|||||||
import org.springframework.beans.BeansException
|
import org.springframework.beans.BeansException
|
||||||
import org.springframework.context.ApplicationContext
|
import org.springframework.context.ApplicationContext
|
||||||
import org.springframework.context.ApplicationContextAware
|
import org.springframework.context.ApplicationContextAware
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
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 org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -22,16 +23,6 @@ import java.util.concurrent.TimeUnit
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class ApiHealthCheckService(
|
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
|
private val rpcNodeService: RpcNodeService
|
||||||
) : ApplicationContextAware {
|
) : 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 避免循环依赖)
|
* 获取 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)
|
private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,7 +91,9 @@ class ApiHealthCheckService(
|
|||||||
async { checkDataApi() },
|
async { checkDataApi() },
|
||||||
async { checkGammaApi() },
|
async { checkGammaApi() },
|
||||||
async { checkPolygonRpc() },
|
async { checkPolygonRpc() },
|
||||||
async { checkPolymarketWebSocket() },
|
async { checkPolymarketRtdsWebSocket() },
|
||||||
|
async { checkPolymarketActivityWebSocket() },
|
||||||
|
async { checkUnifiedOnChainWebSocket() },
|
||||||
async { checkBuilderRelayerApi() },
|
async { checkBuilderRelayerApi() },
|
||||||
async { checkGitHubApi() }
|
async { checkGitHubApi() }
|
||||||
)
|
)
|
||||||
@@ -106,7 +110,7 @@ class ApiHealthCheckService(
|
|||||||
* 检查 Polymarket CLOB API
|
* 检查 Polymarket CLOB API
|
||||||
*/
|
*/
|
||||||
private suspend fun checkClobApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
private suspend fun checkClobApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||||
val url = "$clobBaseUrl/"
|
val url = "${PolymarketConstants.CLOB_BASE_URL}/"
|
||||||
checkApi("Polymarket CLOB API", url)
|
checkApi("Polymarket CLOB API", url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +118,7 @@ class ApiHealthCheckService(
|
|||||||
* 检查 Polymarket Data API
|
* 检查 Polymarket Data API
|
||||||
*/
|
*/
|
||||||
private suspend fun checkDataApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
private suspend fun checkDataApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||||
val url = "$dataApiBaseUrl/"
|
val url = "${PolymarketConstants.DATA_API_BASE_URL}/"
|
||||||
checkApi("Polymarket Data API", url)
|
checkApi("Polymarket Data API", url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +135,7 @@ class ApiHealthCheckService(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
// 使用 /markets 接口检查(不传参数,返回空列表或少量市场数据)
|
// 使用 /markets 接口检查(不传参数,返回空列表或少量市场数据)
|
||||||
val url = "$gammaBaseUrl/markets"
|
val url = "${PolymarketConstants.GAMMA_BASE_URL}/markets"
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.get()
|
.get()
|
||||||
@@ -176,7 +180,7 @@ class ApiHealthCheckService(
|
|||||||
logger.warn("检查 Polymarket Gamma API 失败", e)
|
logger.warn("检查 Polymarket Gamma API 失败", e)
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Polymarket Gamma API",
|
name = "Polymarket Gamma API",
|
||||||
url = "$gammaBaseUrl/markets",
|
url = "${PolymarketConstants.GAMMA_BASE_URL}/markets",
|
||||||
status = "error",
|
status = "error",
|
||||||
message = e.message ?: "连接失败"
|
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 {
|
try {
|
||||||
// 检查订单推送服务的连接状态
|
|
||||||
val orderPushService = getOrderPushService()
|
val orderPushService = getOrderPushService()
|
||||||
val orderPushStatuses = orderPushService?.getConnectionStatuses() ?: emptyMap()
|
val statuses = orderPushService?.getConnectionStatuses() ?: emptyMap()
|
||||||
val orderPushConnected = orderPushStatuses.values.any { it }
|
val total = statuses.size
|
||||||
val orderPushTotal = orderPushStatuses.size
|
val connected = statuses.values.count { it }
|
||||||
val orderPushConnectedCount = orderPushStatuses.values.count { it }
|
|
||||||
|
|
||||||
// 检查跟单 WebSocket 服务的连接状态
|
if (total == 0) {
|
||||||
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 连接
|
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Polymarket WebSocket",
|
name = "Polymarket RTDS WebSocket",
|
||||||
url = url,
|
url = PolymarketConstants.RTDS_WS_URL,
|
||||||
status = "skipped",
|
status = "skipped",
|
||||||
message = "未配置 WebSocket 连接"
|
message = "未配置账户连接"
|
||||||
)
|
)
|
||||||
} else if (hasAnyConnection) {
|
} else if (connected > 0) {
|
||||||
// 至少有一个连接是活跃的
|
val message = if (connected == total) {
|
||||||
val message = if (connectedConnections == totalConnections) {
|
"所有账户连接正常 ($connected/$total)"
|
||||||
"所有连接正常 ($connectedConnections/$totalConnections)"
|
|
||||||
} else {
|
} else {
|
||||||
"部分连接正常 ($connectedConnections/$totalConnections)"
|
"部分账户连接正常 ($connected/$total)"
|
||||||
}
|
}
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Polymarket WebSocket",
|
name = "Polymarket RTDS WebSocket",
|
||||||
url = url,
|
url = PolymarketConstants.RTDS_WS_URL,
|
||||||
status = "success",
|
status = "success",
|
||||||
message = message
|
message = message
|
||||||
// 不设置 responseTime,WebSocket 不显示延时
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 所有连接都断开
|
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Polymarket WebSocket",
|
name = "Polymarket RTDS WebSocket",
|
||||||
url = url,
|
url = PolymarketConstants.RTDS_WS_URL,
|
||||||
status = "error",
|
status = "error",
|
||||||
message = "所有连接断开 ($connectedConnections/$totalConnections)"
|
message = "所有账户连接断开 (0/$total)"
|
||||||
// 不设置 responseTime,WebSocket 不显示延时
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("检查 Polymarket WebSocket 状态失败", e)
|
logger.warn("检查 Polymarket RTDS WebSocket 状态失败", e)
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Polymarket WebSocket",
|
name = "Polymarket RTDS WebSocket",
|
||||||
url = polymarketWsUrl,
|
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",
|
status = "error",
|
||||||
message = "检查失败:${e.message}"
|
message = "检查失败:${e.message}"
|
||||||
)
|
)
|
||||||
@@ -390,19 +468,10 @@ class ApiHealthCheckService(
|
|||||||
private suspend fun checkBuilderRelayerApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
private suspend fun checkBuilderRelayerApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||||
val relayClientService = getRelayClientService()
|
val relayClientService = getRelayClientService()
|
||||||
|
|
||||||
if (builderRelayerUrl.isBlank()) {
|
|
||||||
return@withContext ApiHealthCheckDto(
|
|
||||||
name = "Builder Relayer API",
|
|
||||||
url = "未配置",
|
|
||||||
status = "skipped",
|
|
||||||
message = "未配置 Builder Relayer URL"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relayClientService == null) {
|
if (relayClientService == null) {
|
||||||
return@withContext ApiHealthCheckDto(
|
return@withContext ApiHealthCheckDto(
|
||||||
name = "Builder Relayer API",
|
name = "Builder Relayer API",
|
||||||
url = builderRelayerUrl,
|
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
status = "error",
|
status = "error",
|
||||||
message = "服务未初始化"
|
message = "服务未初始化"
|
||||||
)
|
)
|
||||||
@@ -411,7 +480,7 @@ class ApiHealthCheckService(
|
|||||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||||
return@withContext ApiHealthCheckDto(
|
return@withContext ApiHealthCheckDto(
|
||||||
name = "Builder Relayer API",
|
name = "Builder Relayer API",
|
||||||
url = builderRelayerUrl,
|
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
status = "skipped",
|
status = "skipped",
|
||||||
message = "Builder API Key 未配置"
|
message = "Builder API Key 未配置"
|
||||||
)
|
)
|
||||||
@@ -423,7 +492,7 @@ class ApiHealthCheckService(
|
|||||||
onSuccess = { responseTime ->
|
onSuccess = { responseTime ->
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Builder Relayer API",
|
name = "Builder Relayer API",
|
||||||
url = builderRelayerUrl,
|
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
status = "success",
|
status = "success",
|
||||||
message = "连接成功",
|
message = "连接成功",
|
||||||
responseTime = responseTime
|
responseTime = responseTime
|
||||||
@@ -432,7 +501,7 @@ class ApiHealthCheckService(
|
|||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Builder Relayer API",
|
name = "Builder Relayer API",
|
||||||
url = builderRelayerUrl,
|
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
status = "error",
|
status = "error",
|
||||||
message = e.message ?: "连接失败"
|
message = e.message ?: "连接失败"
|
||||||
)
|
)
|
||||||
@@ -442,7 +511,7 @@ class ApiHealthCheckService(
|
|||||||
logger.warn("检查 Builder Relayer API 失败", e)
|
logger.warn("检查 Builder Relayer API 失败", e)
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Builder Relayer API",
|
name = "Builder Relayer API",
|
||||||
url = builderRelayerUrl,
|
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
status = "error",
|
status = "error",
|
||||||
message = e.message ?: "连接失败"
|
message = e.message ?: "连接失败"
|
||||||
)
|
)
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.wrbug.polymarketbot.service.system
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.repository.ProcessedTradeRepository
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已处理交易清理服务
|
||||||
|
* 定期清理过期的去重记录
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class ProcessedTradeCleanupService(
|
||||||
|
private val processedTradeRepository: ProcessedTradeRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val logger = LoggerFactory.getLogger(ProcessedTradeCleanupService::class.java)
|
||||||
|
|
||||||
|
// 保留时间:1小时(3600000毫秒)
|
||||||
|
// 说明:重复订单通常10秒后就不会再出现,保留10分钟是为了安全起见
|
||||||
|
private const val RETENTION_MS = 600_000L
|
||||||
|
|
||||||
|
// 定时清理间隔:10分钟(600000毫秒)
|
||||||
|
private const val CLEANUP_INTERVAL_MS = 600_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时清理过期记录
|
||||||
|
* 每10分钟执行一次
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = CLEANUP_INTERVAL_MS)
|
||||||
|
@Transactional
|
||||||
|
fun cleanupExpiredProcessedTrades() {
|
||||||
|
try {
|
||||||
|
val expireTime = System.currentTimeMillis() - RETENTION_MS
|
||||||
|
val deletedCount = processedTradeRepository.deleteByProcessedAtBefore(expireTime)
|
||||||
|
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
logger.info("清理过期已处理交易记录: deletedCount=$deletedCount, expireTime=$expireTime")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("清理过期已处理交易记录失败", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+53
-48
@@ -3,11 +3,11 @@ package com.wrbug.polymarketbot.service.system
|
|||||||
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
||||||
@@ -24,8 +24,6 @@ import java.math.BigInteger
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class RelayClientService(
|
class RelayClientService(
|
||||||
@Value("\${polymarket.builder.relayer-url:}")
|
|
||||||
private val builderRelayerUrl: String,
|
|
||||||
private val retrofitFactory: RetrofitFactory,
|
private val retrofitFactory: RetrofitFactory,
|
||||||
private val systemConfigService: SystemConfigService,
|
private val systemConfigService: SystemConfigService,
|
||||||
private val rpcNodeService: RpcNodeService
|
private val rpcNodeService: RpcNodeService
|
||||||
@@ -54,10 +52,10 @@ class RelayClientService(
|
|||||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||||
val builderSecret = systemConfigService.getBuilderSecret()
|
val builderSecret = systemConfigService.getBuilderSecret()
|
||||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||||
|
|
||||||
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||||
return retrofitFactory.createBuilderRelayerApi(
|
return retrofitFactory.createBuilderRelayerApi(
|
||||||
relayerUrl = builderRelayerUrl,
|
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
apiKey = builderApiKey!!,
|
apiKey = builderApiKey!!,
|
||||||
secret = builderSecret!!,
|
secret = builderSecret!!,
|
||||||
passphrase = builderPassphrase!!
|
passphrase = builderPassphrase!!
|
||||||
@@ -74,19 +72,19 @@ class RelayClientService(
|
|||||||
builderSecret: String?,
|
builderSecret: String?,
|
||||||
builderPassphrase: String?
|
builderPassphrase: String?
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return builderRelayerUrl.isNotBlank() &&
|
return PolymarketConstants.BUILDER_RELAYER_URL.isNotBlank() &&
|
||||||
builderApiKey != null && builderApiKey.isNotBlank() &&
|
builderApiKey != null && builderApiKey.isNotBlank() &&
|
||||||
builderSecret != null && builderSecret.isNotBlank() &&
|
builderSecret != null && builderSecret.isNotBlank() &&
|
||||||
builderPassphrase != null && builderPassphrase.isNotBlank()
|
builderPassphrase != null && builderPassphrase.isNotBlank()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查 Builder API Key 是否已配置
|
* 检查 Builder API Key 是否已配置
|
||||||
*/
|
*/
|
||||||
fun isBuilderApiKeyConfigured(): Boolean {
|
fun isBuilderApiKeyConfigured(): Boolean {
|
||||||
return systemConfigService.isBuilderApiKeyConfigured()
|
return systemConfigService.isBuilderApiKeyConfigured()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查 Builder Relayer API 健康状态(用于 API 健康检查)
|
* 检查 Builder Relayer API 健康状态(用于 API 健康检查)
|
||||||
*/
|
*/
|
||||||
@@ -95,24 +93,24 @@ class RelayClientService(
|
|||||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||||
val builderSecret = systemConfigService.getBuilderSecret()
|
val builderSecret = systemConfigService.getBuilderSecret()
|
||||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||||
|
|
||||||
if (builderApiKey == null || builderSecret == null || builderPassphrase == null) {
|
if (builderApiKey == null || builderSecret == null || builderPassphrase == null) {
|
||||||
return Result.failure(IllegalStateException("Builder API Key 未配置"))
|
return Result.failure(IllegalStateException("Builder API Key 未配置"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||||
relayerUrl = builderRelayerUrl,
|
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
apiKey = builderApiKey,
|
apiKey = builderApiKey,
|
||||||
secret = builderSecret,
|
secret = builderSecret,
|
||||||
passphrase = builderPassphrase
|
passphrase = builderPassphrase
|
||||||
)
|
)
|
||||||
|
|
||||||
// 使用一个测试地址来检查 API 是否可用(使用一个已知的地址,如零地址)
|
// 使用一个测试地址来检查 API 是否可用(使用一个已知的地址,如零地址)
|
||||||
val testAddress = "0x0000000000000000000000000000000000000000"
|
val testAddress = "0x0000000000000000000000000000000000000000"
|
||||||
val startTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
val response = relayerApi.getDeployed(testAddress)
|
val response = relayerApi.getDeployed(testAddress)
|
||||||
val responseTime = System.currentTimeMillis() - startTime
|
val responseTime = System.currentTimeMillis() - startTime
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
Result.success(responseTime)
|
Result.success(responseTime)
|
||||||
} else {
|
} else {
|
||||||
@@ -228,11 +226,18 @@ class RelayClientService(
|
|||||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||||
val builderSecret = systemConfigService.getBuilderSecret()
|
val builderSecret = systemConfigService.getBuilderSecret()
|
||||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||||
|
|
||||||
// 优先使用 Builder Relayer(Gasless)
|
// 优先使用 Builder Relayer(Gasless)
|
||||||
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||||
logger.info("使用 Builder Relayer 执行 Gasless 交易")
|
logger.info("使用 Builder Relayer 执行 Gasless 交易")
|
||||||
return executeViaBuilderRelayer(privateKey, proxyAddress, safeTx, builderApiKey!!, builderSecret!!, builderPassphrase!!)
|
return executeViaBuilderRelayer(
|
||||||
|
privateKey,
|
||||||
|
proxyAddress,
|
||||||
|
safeTx,
|
||||||
|
builderApiKey!!,
|
||||||
|
builderSecret!!,
|
||||||
|
builderPassphrase!!
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回退到手动发送交易(需要用户支付 gas)
|
// 回退到手动发送交易(需要用户支付 gas)
|
||||||
@@ -256,9 +261,8 @@ class RelayClientService(
|
|||||||
builderSecret: String,
|
builderSecret: String,
|
||||||
builderPassphrase: String
|
builderPassphrase: String
|
||||||
): Result<String> {
|
): Result<String> {
|
||||||
val rpcApi = polygonRpcApi
|
|
||||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||||
relayerUrl = builderRelayerUrl,
|
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
apiKey = builderApiKey,
|
apiKey = builderApiKey,
|
||||||
secret = builderSecret,
|
secret = builderSecret,
|
||||||
passphrase = builderPassphrase
|
passphrase = builderPassphrase
|
||||||
@@ -320,19 +324,19 @@ class RelayClientService(
|
|||||||
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
|
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
|
||||||
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
||||||
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
|
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
|
||||||
|
|
||||||
// 对带前缀的消息进行 keccak256 哈希
|
// 对带前缀的消息进行 keccak256 哈希
|
||||||
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
||||||
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
||||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||||
keccak256.doFinal(hashWithPrefix, 0)
|
keccak256.doFinal(hashWithPrefix, 0)
|
||||||
|
|
||||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||||
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||||
|
|
||||||
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||||
val packedSignature = splitAndPackSig(safeSignature)
|
val packedSignature = splitAndPackSig(safeSignature)
|
||||||
|
|
||||||
// 调试日志(地址已遮蔽)
|
// 调试日志(地址已遮蔽)
|
||||||
logger.debug("=== Builder Relayer 签名调试 ===")
|
logger.debug("=== Builder Relayer 签名调试 ===")
|
||||||
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
|
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
|
||||||
@@ -358,7 +362,7 @@ class RelayClientService(
|
|||||||
),
|
),
|
||||||
metadata = "Redeem positions via Builder Relayer"
|
metadata = "Redeem positions via Builder Relayer"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
|
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
|
||||||
|
|
||||||
// 调用 Builder Relayer API(认证头通过拦截器添加)
|
// 调用 Builder Relayer API(认证头通过拦截器添加)
|
||||||
@@ -372,7 +376,7 @@ class RelayClientService(
|
|||||||
|
|
||||||
val relayerResponse = response.body()!!
|
val relayerResponse = response.body()!!
|
||||||
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
|
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")
|
logger.info("Builder Relayer 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
|
||||||
return Result.success(txHash)
|
return Result.success(txHash)
|
||||||
@@ -381,14 +385,14 @@ class RelayClientService(
|
|||||||
/**
|
/**
|
||||||
* 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
* 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||||
* 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
|
* 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
|
||||||
*
|
*
|
||||||
* TypeScript 实现流程:
|
* TypeScript 实现流程:
|
||||||
* 1. 从签名字符串中提取 v(最后 2 个字符)
|
* 1. 从签名字符串中提取 v(最后 2 个字符)
|
||||||
* 2. 调整 v 值(0,1 -> +31; 27,28 -> +4)
|
* 2. 调整 v 值(0,1 -> +31; 27,28 -> +4)
|
||||||
* 3. 修改签名字符串(替换最后 2 个字符)
|
* 3. 修改签名字符串(替换最后 2 个字符)
|
||||||
* 4. 从修改后的签名字符串中提取 r, s, v(作为十进制字符串)
|
* 4. 从修改后的签名字符串中提取 r, s, v(作为十进制字符串)
|
||||||
* 5. 使用 encodePacked 打包:uint256(BigInt(r)) + uint256(BigInt(s)) + uint8(parseInt(v))
|
* 5. 使用 encodePacked 打包:uint256(BigInt(r)) + uint256(BigInt(s)) + uint8(parseInt(v))
|
||||||
*
|
*
|
||||||
* 关键:encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
|
* 关键:encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
|
||||||
*/
|
*/
|
||||||
private fun splitAndPackSig(signature: org.web3j.crypto.Sign.SignatureData): String {
|
private fun splitAndPackSig(signature: org.web3j.crypto.Sign.SignatureData): String {
|
||||||
@@ -403,37 +407,37 @@ class RelayClientService(
|
|||||||
}
|
}
|
||||||
val originalVHex = String.format("%02x", originalV)
|
val originalVHex = String.format("%02x", originalV)
|
||||||
val sigString = "0x$rHex$sHex$originalVHex" // 130 个十六进制字符(65 字节)
|
val sigString = "0x$rHex$sHex$originalVHex" // 130 个十六进制字符(65 字节)
|
||||||
|
|
||||||
// 2. 从签名字符串中提取 v(最后 2 个字符,作为十六进制)
|
// 2. 从签名字符串中提取 v(最后 2 个字符,作为十六进制)
|
||||||
val sigV = sigString.substring(sigString.length - 2).toInt(16)
|
val sigV = sigString.substring(sigString.length - 2).toInt(16)
|
||||||
|
|
||||||
// 3. 调整 v 值(参考 TypeScript 实现)
|
// 3. 调整 v 值(参考 TypeScript 实现)
|
||||||
val adjustedV = when (sigV) {
|
val adjustedV = when (sigV) {
|
||||||
0, 1 -> sigV + 31
|
0, 1 -> sigV + 31
|
||||||
27, 28 -> sigV + 4
|
27, 28 -> sigV + 4
|
||||||
else -> throw IllegalArgumentException("Invalid signature v value: $sigV")
|
else -> throw IllegalArgumentException("Invalid signature v value: $sigV")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 修改签名字符串(替换最后 2 个字符)
|
// 4. 修改签名字符串(替换最后 2 个字符)
|
||||||
val modifiedSigString = sigString.substring(0, sigString.length - 2) + String.format("%02x", adjustedV)
|
val modifiedSigString = sigString.substring(0, sigString.length - 2) + String.format("%02x", adjustedV)
|
||||||
|
|
||||||
// 5. 从修改后的签名字符串中提取 r, s, v(作为十六进制字符串)
|
// 5. 从修改后的签名字符串中提取 r, s, v(作为十六进制字符串)
|
||||||
// modifiedSigString 格式:0x + r(64) + s(64) + v(2) = 132 个字符
|
// modifiedSigString 格式:0x + r(64) + s(64) + v(2) = 132 个字符
|
||||||
val rHexStr = modifiedSigString.substring(2, 66) // 64 个字符(十六进制)
|
val rHexStr = modifiedSigString.substring(2, 66) // 64 个字符(十六进制)
|
||||||
val sHexStr = modifiedSigString.substring(66, 130) // 64 个字符(十六进制)
|
val sHexStr = modifiedSigString.substring(66, 130) // 64 个字符(十六进制)
|
||||||
val vHexStr = modifiedSigString.substring(130, 132) // 2 个字符(十六进制)
|
val vHexStr = modifiedSigString.substring(130, 132) // 2 个字符(十六进制)
|
||||||
|
|
||||||
// 6. 转换为 BigInteger 和 Int(模拟 TypeScript 的 BigInt 和 parseInt)
|
// 6. 转换为 BigInteger 和 Int(模拟 TypeScript 的 BigInt 和 parseInt)
|
||||||
val rBigInt = BigInteger(rHexStr, 16)
|
val rBigInt = BigInteger(rHexStr, 16)
|
||||||
val sBigInt = BigInteger(sHexStr, 16)
|
val sBigInt = BigInteger(sHexStr, 16)
|
||||||
val vInt = vHexStr.toInt(16)
|
val vInt = vHexStr.toInt(16)
|
||||||
|
|
||||||
// 7. 使用 encodePacked 打包:uint256(r) + uint256(s) + uint8(v)
|
// 7. 使用 encodePacked 打包:uint256(r) + uint256(s) + uint8(v)
|
||||||
// encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
|
// encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
|
||||||
val rEncoded = EthereumUtils.encodeUint256(rBigInt) // 64 个十六进制字符
|
val rEncoded = EthereumUtils.encodeUint256(rBigInt) // 64 个十六进制字符
|
||||||
val sEncoded = EthereumUtils.encodeUint256(sBigInt) // 64 个十六进制字符
|
val sEncoded = EthereumUtils.encodeUint256(sBigInt) // 64 个十六进制字符
|
||||||
val vEncoded = String.format("%02x", vInt) // 2 个十六进制字符
|
val vEncoded = String.format("%02x", vInt) // 2 个十六进制字符
|
||||||
|
|
||||||
return "0x$rEncoded$sEncoded$vEncoded"
|
return "0x$rEncoded$sEncoded$vEncoded"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,13 +508,13 @@ class RelayClientService(
|
|||||||
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
|
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
|
||||||
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
||||||
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
|
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
|
||||||
|
|
||||||
// 对带前缀的消息进行 keccak256 哈希
|
// 对带前缀的消息进行 keccak256 哈希
|
||||||
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
||||||
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
||||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||||
keccak256.doFinal(hashWithPrefix, 0)
|
keccak256.doFinal(hashWithPrefix, 0)
|
||||||
|
|
||||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||||
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||||
|
|
||||||
@@ -572,7 +576,8 @@ class RelayClientService(
|
|||||||
redeemCallData: String,
|
redeemCallData: String,
|
||||||
safeSignatureHex: String
|
safeSignatureHex: String
|
||||||
): 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 encodedTo = EthereumUtils.encodeAddress(safeTx.to)
|
||||||
val encodedValue = EthereumUtils.encodeUint256(BigInteger.ZERO)
|
val encodedValue = EthereumUtils.encodeUint256(BigInteger.ZERO)
|
||||||
@@ -600,20 +605,20 @@ class RelayClientService(
|
|||||||
val encodedSignatures = safeSignatureHex
|
val encodedSignatures = safeSignatureHex
|
||||||
|
|
||||||
return "0x" + execFunctionSelector.removePrefix("0x") +
|
return "0x" + execFunctionSelector.removePrefix("0x") +
|
||||||
encodedTo +
|
encodedTo +
|
||||||
encodedValue +
|
encodedValue +
|
||||||
encodedDataOffset +
|
encodedDataOffset +
|
||||||
encodedDataLength +
|
encodedDataLength +
|
||||||
encodedData +
|
encodedData +
|
||||||
encodedOperation +
|
encodedOperation +
|
||||||
encodedSafeTxGas +
|
encodedSafeTxGas +
|
||||||
encodedBaseGas +
|
encodedBaseGas +
|
||||||
encodedGasPrice +
|
encodedGasPrice +
|
||||||
encodedGasToken +
|
encodedGasToken +
|
||||||
encodedRefundReceiver +
|
encodedRefundReceiver +
|
||||||
encodedSignaturesOffset +
|
encodedSignaturesOffset +
|
||||||
encodedSignaturesLength +
|
encodedSignaturesLength +
|
||||||
encodedSignatures
|
encodedSignatures
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+13
-4
@@ -77,6 +77,7 @@ class TelegramNotificationService(
|
|||||||
* @param apiPassphrase API Passphrase(可选,用于查询订单详情)
|
* @param apiPassphrase API Passphrase(可选,用于查询订单详情)
|
||||||
* @param walletAddressForApi 钱包地址(可选,用于查询订单详情)
|
* @param walletAddressForApi 钱包地址(可选,用于查询订单详情)
|
||||||
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||||
|
* @param orderTime 订单时间(可选,如果提供则使用订单创建时间,否则使用当前通知时间)
|
||||||
*/
|
*/
|
||||||
suspend fun sendOrderSuccessNotification(
|
suspend fun sendOrderSuccessNotification(
|
||||||
orderId: String?,
|
orderId: String?,
|
||||||
@@ -96,7 +97,8 @@ class TelegramNotificationService(
|
|||||||
walletAddressForApi: String? = null,
|
walletAddressForApi: String? = null,
|
||||||
locale: java.util.Locale? = null,
|
locale: java.util.Locale? = null,
|
||||||
leaderName: String? = null, // Leader 名称(备注)
|
leaderName: String? = null, // Leader 名称(备注)
|
||||||
configName: String? = null // 跟单配置名
|
configName: String? = null, // 跟单配置名
|
||||||
|
orderTime: Long? = null // 订单创建时间(毫秒时间戳),用于通知中的时间显示
|
||||||
) {
|
) {
|
||||||
// 1. 如果提供了 orderId,检查是否已发送过通知(去重)
|
// 1. 如果提供了 orderId,检查是否已发送过通知(去重)
|
||||||
if (orderId != null) {
|
if (orderId != null) {
|
||||||
@@ -189,7 +191,8 @@ class TelegramNotificationService(
|
|||||||
walletAddress = walletAddress,
|
walletAddress = walletAddress,
|
||||||
locale = currentLocale,
|
locale = currentLocale,
|
||||||
leaderName = leaderName,
|
leaderName = leaderName,
|
||||||
configName = configName
|
configName = configName,
|
||||||
|
orderTime = orderTime
|
||||||
)
|
)
|
||||||
sendMessage(message)
|
sendMessage(message)
|
||||||
}
|
}
|
||||||
@@ -699,7 +702,8 @@ class TelegramNotificationService(
|
|||||||
walletAddress: String?,
|
walletAddress: String?,
|
||||||
locale: java.util.Locale,
|
locale: java.util.Locale,
|
||||||
leaderName: String? = null, // Leader 名称(备注)
|
leaderName: String? = null, // Leader 名称(备注)
|
||||||
configName: String? = null // 跟单配置名
|
configName: String? = null, // 跟单配置名
|
||||||
|
orderTime: Long? = null // 订单创建时间(毫秒时间戳)
|
||||||
): String {
|
): String {
|
||||||
|
|
||||||
// 获取多语言文本
|
// 获取多语言文本
|
||||||
@@ -749,7 +753,12 @@ class TelegramNotificationService(
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
val time = DateUtils.formatDateTime()
|
// 使用订单时间(如果提供),否则使用当前通知时间
|
||||||
|
val time = if (orderTime != null) {
|
||||||
|
DateUtils.formatDateTime(orderTime)
|
||||||
|
} else {
|
||||||
|
DateUtils.formatDateTime()
|
||||||
|
}
|
||||||
|
|
||||||
// 转义 HTML 特殊字符
|
// 转义 HTML 特殊字符
|
||||||
val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">")
|
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.PolymarketClobApi
|
||||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||||
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
@@ -18,7 +19,6 @@ import okhttp3.Response
|
|||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
@@ -34,10 +34,6 @@ import jakarta.annotation.PreDestroy
|
|||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class RetrofitFactory(
|
class RetrofitFactory(
|
||||||
@Value("\${polymarket.clob.base-url}")
|
|
||||||
private val clobBaseUrl: String,
|
|
||||||
@Value("\${polymarket.gamma.base-url}")
|
|
||||||
private val gammaBaseUrl: String,
|
|
||||||
private val gson: Gson
|
private val gson: Gson
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -58,10 +54,10 @@ class RetrofitFactory(
|
|||||||
|
|
||||||
// 缓存 Gamma API 客户端(单例)
|
// 缓存 Gamma API 客户端(单例)
|
||||||
private val gammaApi: PolymarketGammaApi by lazy {
|
private val gammaApi: PolymarketGammaApi by lazy {
|
||||||
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
|
val baseUrl = if (PolymarketConstants.GAMMA_BASE_URL.endsWith("/")) {
|
||||||
gammaBaseUrl.dropLast(1)
|
PolymarketConstants.GAMMA_BASE_URL.dropLast(1)
|
||||||
} else {
|
} else {
|
||||||
gammaBaseUrl
|
PolymarketConstants.GAMMA_BASE_URL
|
||||||
}
|
}
|
||||||
|
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
@@ -74,7 +70,7 @@ class RetrofitFactory(
|
|||||||
|
|
||||||
// 缓存 Data API 客户端(单例)
|
// 缓存 Data API 客户端(单例)
|
||||||
private val dataApi: PolymarketDataApi by lazy {
|
private val dataApi: PolymarketDataApi by lazy {
|
||||||
val baseUrl = "https://data-api.polymarket.com"
|
val baseUrl = PolymarketConstants.DATA_API_BASE_URL
|
||||||
|
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl("$baseUrl/")
|
.baseUrl("$baseUrl/")
|
||||||
@@ -113,7 +109,7 @@ class RetrofitFactory(
|
|||||||
// 缓存不带认证的 CLOB API 客户端(单例)
|
// 缓存不带认证的 CLOB API 客户端(单例)
|
||||||
private val clobApiWithoutAuth: PolymarketClobApi by lazy {
|
private val clobApiWithoutAuth: PolymarketClobApi by lazy {
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(sharedOkHttpClient)
|
.client(sharedOkHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
@@ -158,7 +154,7 @@ class RetrofitFactory(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
+2
-3
@@ -1,7 +1,7 @@
|
|||||||
package com.wrbug.polymarketbot.websocket
|
package com.wrbug.polymarketbot.websocket
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.web.socket.*
|
import org.springframework.web.socket.*
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -15,8 +15,7 @@ class PolymarketWebSocketHandler : WebSocketHandler {
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
|
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
|
||||||
|
|
||||||
@Value("\${polymarket.rtds.ws-url}")
|
private val polymarketWsUrl: String = PolymarketConstants.RTDS_WS_URL
|
||||||
private lateinit var polymarketWsUrl: String
|
|
||||||
|
|
||||||
// 存储客户端会话和对应的 Polymarket 连接的映射
|
// 存储客户端会话和对应的 Polymarket 连接的映射
|
||||||
private val clientSessions = ConcurrentHashMap<String, WebSocketSession>()
|
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
|
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
|
||||||
|
|
||||||
# Polymarket API 配置
|
# Polymarket API 配置
|
||||||
polymarket.clob.base-url=https://clob.polymarket.com
|
# 注意:Polymarket API URL 现在使用代码常量(PolymarketConstants),不再从配置文件读取
|
||||||
polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
|
# 如需修改,请修改 com.wrbug.polymarketbot.constants.PolymarketConstants 类
|
||||||
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
|
|
||||||
|
|
||||||
# Builder Relayer 配置(用于 Gasless 交易)
|
# Builder Relayer 配置(用于 Gasless 交易)
|
||||||
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
|
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
|
||||||
# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中
|
# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中
|
||||||
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
|
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
|
||||||
polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/}
|
# 注意:Builder Relayer URL 现在使用代码常量(PolymarketConstants.BUILDER_RELAYER_URL),不再从配置文件读取
|
||||||
|
|
||||||
# 跟单轮询配置
|
# 跟单轮询配置
|
||||||
# 轮询间隔(毫秒),默认2秒
|
# 轮询间隔(毫秒),默认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 '推送已过滤订单(默认关闭)';
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V25: 添加订单来源字段到跟单订单跟踪表
|
||||||
|
-- 用于记录订单是从哪个数据源接收到的(activity-ws 或 onchain-ws)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 添加订单来源字段
|
||||||
|
ALTER TABLE copy_order_tracking
|
||||||
|
ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'unknown' COMMENT '订单来源:activity-ws(Polymarket WebSocket)、onchain-ws(OnChain WebSocket)';
|
||||||
|
|
||||||
|
-- 对于已有数据,设置为默认值 unknown(不影响现有功能)
|
||||||
|
-- 新创建的记录会在创建时自动填充此字段
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-80}:80"
|
- "${SERVER_PORT:-80}:80"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
- 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_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
||||||
- DB_USERNAME=${DB_USERNAME:-root}
|
- DB_USERNAME=${DB_USERNAME:-root}
|
||||||
@@ -33,6 +34,8 @@ services:
|
|||||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||||
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
|
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
|
||||||
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
|
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
|
||||||
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -46,12 +49,14 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${MYSQL_PORT:-3307}:3306"
|
- "${MYSQL_PORT:-3307}:3306"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
||||||
- MYSQL_DATABASE=polyhermes
|
- MYSQL_DATABASE=polyhermes
|
||||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||||
volumes:
|
volumes:
|
||||||
- mysql-data:/var/lib/mysql
|
- mysql-data:/var/lib/mysql
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-80}:80"
|
- "${SERVER_PORT:-80}:80"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
- 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_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
||||||
- DB_USERNAME=${DB_USERNAME:-root}
|
- DB_USERNAME=${DB_USERNAME:-root}
|
||||||
@@ -31,6 +32,8 @@ services:
|
|||||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||||
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
|
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
|
||||||
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
|
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
|
||||||
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -44,12 +47,14 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${MYSQL_PORT:-3307}:3306"
|
- "${MYSQL_PORT:-3307}:3306"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
||||||
- MYSQL_DATABASE=polyhermes
|
- MYSQL_DATABASE=polyhermes
|
||||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||||
volumes:
|
volumes:
|
||||||
- mysql-data:/var/lib/mysql
|
- mysql-data:/var/lib/mysql
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -399,8 +399,8 @@ CopyOrderTrackingService.processTrade(
|
|||||||
### 5.1 application.properties
|
### 5.1 application.properties
|
||||||
|
|
||||||
```properties
|
```properties
|
||||||
# Polymarket WebSocket
|
# 注意:Polymarket API URL 现在使用代码常量(PolymarketConstants),不再从配置文件读取
|
||||||
polymarket.websocket.url=wss://ws-live-data.polymarket.com
|
# 如需修改,请修改 com.wrbug.polymarketbot.constants.PolymarketConstants 类
|
||||||
|
|
||||||
# 监听策略
|
# 监听策略
|
||||||
copy.trading.monitor.strategy=dual
|
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",
|
"deleteConfirmDesc": "This action cannot be undone. Please ensure no copy trading relationships are using this template",
|
||||||
"copySuccess": "Template copied successfully",
|
"copySuccess": "Template copied successfully",
|
||||||
"copyFailed": "Failed to copy template",
|
"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",
|
"minAmountError": "Minimum amount must be >= 1",
|
||||||
"fixedAmountRequired": "Please enter fixed copy trading amount",
|
"fixedAmountRequired": "Please enter fixed copy trading amount",
|
||||||
"invalidNumber": "Please enter a valid number",
|
"invalidNumber": "Please enter a valid number",
|
||||||
@@ -623,6 +625,8 @@
|
|||||||
"maxPricePlaceholder": "Max Price (leave empty for no limit)",
|
"maxPricePlaceholder": "Max Price (leave empty for no limit)",
|
||||||
"supportSell": "Support Sell",
|
"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.",
|
"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",
|
"create": "Create Template",
|
||||||
"createSuccess": "Template created successfully",
|
"createSuccess": "Template created successfully",
|
||||||
"createFailed": "Failed to create template",
|
"createFailed": "Failed to create template",
|
||||||
@@ -687,6 +691,8 @@
|
|||||||
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
|
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
|
||||||
"supportSell": "Support Sell",
|
"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.",
|
"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"
|
"invalidNumber": "Please enter a valid number"
|
||||||
},
|
},
|
||||||
"copyTradingAdd": {
|
"copyTradingAdd": {
|
||||||
@@ -783,6 +789,8 @@
|
|||||||
"advancedSettings": "Advanced Settings",
|
"advancedSettings": "Advanced Settings",
|
||||||
"pushFailedOrders": "Push Failed Orders",
|
"pushFailedOrders": "Push Failed Orders",
|
||||||
"pushFailedOrdersTooltip": "When enabled, failed orders will be pushed to Telegram",
|
"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",
|
"autoRedeem": "Auto Redeem",
|
||||||
"autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect",
|
"autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect",
|
||||||
"builderApiKeyNotConfigured": "Builder API Key Not Configured",
|
"builderApiKeyNotConfigured": "Builder API Key Not Configured",
|
||||||
@@ -888,6 +896,8 @@
|
|||||||
"advancedSettings": "Advanced Settings",
|
"advancedSettings": "Advanced Settings",
|
||||||
"pushFailedOrders": "Push Failed Orders",
|
"pushFailedOrders": "Push Failed Orders",
|
||||||
"pushFailedOrdersTooltip": "When enabled, failed orders will be pushed to Telegram",
|
"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",
|
"autoRedeem": "Auto Redeem",
|
||||||
"autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect",
|
"autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect",
|
||||||
"builderApiKeyNotConfigured": "Builder API Key Not Configured",
|
"builderApiKeyNotConfigured": "Builder API Key Not Configured",
|
||||||
@@ -1089,6 +1099,7 @@
|
|||||||
"price": "Price",
|
"price": "Price",
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
"filterMarketId": "Filter Market ID",
|
"filterMarketId": "Filter Market ID",
|
||||||
|
"filterMarketTitle": "Filter Market Title",
|
||||||
"filterSide": "Filter Side",
|
"filterSide": "Filter Side",
|
||||||
"filterStatus": "Filter Status",
|
"filterStatus": "Filter Status",
|
||||||
"filterSellOrderId": "Filter Sell Order ID",
|
"filterSellOrderId": "Filter Sell Order ID",
|
||||||
@@ -1099,12 +1110,15 @@
|
|||||||
"groupByMarket": "Group by Market",
|
"groupByMarket": "Group by Market",
|
||||||
"expandAll": "Expand All",
|
"expandAll": "Expand All",
|
||||||
"collapseAll": "Collapse All",
|
"collapseAll": "Collapse All",
|
||||||
"allFullyMatched": "All Fully Matched",
|
"allFullySold": "All Fully Sold",
|
||||||
"partiallyMatched": "Partially Matched",
|
"notSold": "Not Sold",
|
||||||
|
"partiallySold": "Partially Sold",
|
||||||
"orderCount": "Order Count",
|
"orderCount": "Order Count",
|
||||||
"totalAmount": "Total Amount",
|
"totalAmount": "Total Amount",
|
||||||
"totalPnl": "Total PnL",
|
|
||||||
"statusBreakdown": "Status",
|
"statusBreakdown": "Status",
|
||||||
|
"allFullyMatched": "All Fully Sold",
|
||||||
|
"partiallyMatched": "Partially Sold",
|
||||||
|
"totalPnl": "Total PnL",
|
||||||
"markets": "markets",
|
"markets": "markets",
|
||||||
"fetchBuyOrdersFailed": "Failed to fetch buy orders",
|
"fetchBuyOrdersFailed": "Failed to fetch buy orders",
|
||||||
"fetchSellOrdersFailed": "Failed to fetch sell orders",
|
"fetchSellOrdersFailed": "Failed to fetch sell orders",
|
||||||
@@ -1116,7 +1130,6 @@
|
|||||||
"totalMatchedOrders": "Total Matched Orders",
|
"totalMatchedOrders": "Total Matched Orders",
|
||||||
"totalBuyAmount": "Total Buy Amount",
|
"totalBuyAmount": "Total Buy Amount",
|
||||||
"totalSellAmount": "Total Sell Amount",
|
"totalSellAmount": "Total Sell Amount",
|
||||||
"totalPnl": "Total PnL",
|
|
||||||
"totalRealizedPnl": "Total Realized PnL",
|
"totalRealizedPnl": "Total Realized PnL",
|
||||||
"totalUnrealizedPnl": "Total Unrealized PnL",
|
"totalUnrealizedPnl": "Total Unrealized PnL",
|
||||||
"winRate": "Win Rate",
|
"winRate": "Win Rate",
|
||||||
@@ -1185,4 +1198,4 @@
|
|||||||
"providerChainstack": "Chainstack",
|
"providerChainstack": "Chainstack",
|
||||||
"providerGetBlock": "GetBlock"
|
"providerGetBlock": "GetBlock"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -571,6 +571,8 @@
|
|||||||
"deleteConfirmDesc": "删除后无法恢复,请确保没有跟单关系在使用该模板",
|
"deleteConfirmDesc": "删除后无法恢复,请确保没有跟单关系在使用该模板",
|
||||||
"copySuccess": "复制模板成功",
|
"copySuccess": "复制模板成功",
|
||||||
"copyFailed": "复制模板失败",
|
"copyFailed": "复制模板失败",
|
||||||
|
"pushFilteredOrders": "推送已过滤订单",
|
||||||
|
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
|
||||||
"minAmountError": "最小金额必须 >= 1",
|
"minAmountError": "最小金额必须 >= 1",
|
||||||
"fixedAmountRequired": "请输入固定跟单金额",
|
"fixedAmountRequired": "请输入固定跟单金额",
|
||||||
"invalidNumber": "请输入有效的数字",
|
"invalidNumber": "请输入有效的数字",
|
||||||
@@ -623,6 +625,8 @@
|
|||||||
"maxPricePlaceholder": "最高价(留空不限制)",
|
"maxPricePlaceholder": "最高价(留空不限制)",
|
||||||
"supportSell": "跟单卖出",
|
"supportSell": "跟单卖出",
|
||||||
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
|
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
|
||||||
|
"pushFilteredOrders": "推送已过滤订单",
|
||||||
|
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
|
||||||
"create": "创建模板",
|
"create": "创建模板",
|
||||||
"createSuccess": "创建模板成功",
|
"createSuccess": "创建模板成功",
|
||||||
"createFailed": "创建模板失败",
|
"createFailed": "创建模板失败",
|
||||||
@@ -687,6 +691,8 @@
|
|||||||
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
|
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
|
||||||
"supportSell": "跟单卖出",
|
"supportSell": "跟单卖出",
|
||||||
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
|
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
|
||||||
|
"pushFilteredOrders": "推送已过滤订单",
|
||||||
|
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
|
||||||
"invalidNumber": "请输入有效的数字"
|
"invalidNumber": "请输入有效的数字"
|
||||||
},
|
},
|
||||||
"copyTradingAdd": {
|
"copyTradingAdd": {
|
||||||
@@ -699,6 +705,8 @@
|
|||||||
"advancedSettings": "高级设置",
|
"advancedSettings": "高级设置",
|
||||||
"pushFailedOrders": "推送失败订单",
|
"pushFailedOrders": "推送失败订单",
|
||||||
"pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram",
|
"pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram",
|
||||||
|
"pushFilteredOrders": "推送已过滤订单",
|
||||||
|
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
|
||||||
"autoRedeem": "自动赎回",
|
"autoRedeem": "自动赎回",
|
||||||
"autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效",
|
"autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效",
|
||||||
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
||||||
@@ -812,6 +820,8 @@
|
|||||||
"advancedSettings": "高级设置",
|
"advancedSettings": "高级设置",
|
||||||
"pushFailedOrders": "推送失败订单",
|
"pushFailedOrders": "推送失败订单",
|
||||||
"pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram",
|
"pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram",
|
||||||
|
"pushFilteredOrders": "推送已过滤订单",
|
||||||
|
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
|
||||||
"autoRedeem": "自动赎回",
|
"autoRedeem": "自动赎回",
|
||||||
"autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效",
|
"autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效",
|
||||||
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
||||||
@@ -1070,8 +1080,8 @@
|
|||||||
"sellStatus": "卖出状态",
|
"sellStatus": "卖出状态",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"statusFilled": "未成交",
|
"statusFilled": "未成交",
|
||||||
"statusPartiallySold": "部分成交",
|
"statusPartiallySold": "部分卖出",
|
||||||
"statusFullySold": "全部成交",
|
"statusFullySold": "全部卖出",
|
||||||
"realizedPnl": "已实现盈亏",
|
"realizedPnl": "已实现盈亏",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"matchedAt": "匹配时间",
|
"matchedAt": "匹配时间",
|
||||||
@@ -1089,6 +1099,7 @@
|
|||||||
"price": "价格",
|
"price": "价格",
|
||||||
"amount": "金额",
|
"amount": "金额",
|
||||||
"filterMarketId": "筛选市场ID",
|
"filterMarketId": "筛选市场ID",
|
||||||
|
"filterMarketTitle": "筛选市场标题",
|
||||||
"filterSide": "筛选方向",
|
"filterSide": "筛选方向",
|
||||||
"filterStatus": "筛选状态",
|
"filterStatus": "筛选状态",
|
||||||
"filterSellOrderId": "筛选卖出订单ID",
|
"filterSellOrderId": "筛选卖出订单ID",
|
||||||
@@ -1099,8 +1110,9 @@
|
|||||||
"groupByMarket": "按市场分组",
|
"groupByMarket": "按市场分组",
|
||||||
"expandAll": "展开全部",
|
"expandAll": "展开全部",
|
||||||
"collapseAll": "折叠全部",
|
"collapseAll": "折叠全部",
|
||||||
"allFullyMatched": "全部成交",
|
"allFullySold": "全部卖出",
|
||||||
"partiallyMatched": "部分成交",
|
"notSold": "未卖出",
|
||||||
|
"partiallySold": "部分卖出",
|
||||||
"orderCount": "订单数",
|
"orderCount": "订单数",
|
||||||
"totalAmount": "总金额",
|
"totalAmount": "总金额",
|
||||||
"statusBreakdown": "状态",
|
"statusBreakdown": "状态",
|
||||||
@@ -1184,4 +1196,4 @@
|
|||||||
"providerChainstack": "Chainstack",
|
"providerChainstack": "Chainstack",
|
||||||
"providerGetBlock": "GetBlock"
|
"providerGetBlock": "GetBlock"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -571,6 +571,8 @@
|
|||||||
"deleteConfirmDesc": "刪除後無法恢復,請確保沒有跟單關係在使用該模板",
|
"deleteConfirmDesc": "刪除後無法恢復,請確保沒有跟單關係在使用該模板",
|
||||||
"copySuccess": "複製模板成功",
|
"copySuccess": "複製模板成功",
|
||||||
"copyFailed": "複製模板失敗",
|
"copyFailed": "複製模板失敗",
|
||||||
|
"pushFilteredOrders": "推送已過濾訂單",
|
||||||
|
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
|
||||||
"minAmountError": "最小金額必須 >= 1",
|
"minAmountError": "最小金額必須 >= 1",
|
||||||
"fixedAmountRequired": "請輸入固定跟單金額",
|
"fixedAmountRequired": "請輸入固定跟單金額",
|
||||||
"invalidNumber": "請輸入有效的數字",
|
"invalidNumber": "請輸入有效的數字",
|
||||||
@@ -623,6 +625,8 @@
|
|||||||
"maxPricePlaceholder": "最高價(留空不限制)",
|
"maxPricePlaceholder": "最高價(留空不限制)",
|
||||||
"supportSell": "跟單賣出",
|
"supportSell": "跟單賣出",
|
||||||
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
|
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
|
||||||
|
"pushFilteredOrders": "推送已過濾訂單",
|
||||||
|
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
|
||||||
"create": "創建模板",
|
"create": "創建模板",
|
||||||
"createSuccess": "創建模板成功",
|
"createSuccess": "創建模板成功",
|
||||||
"createFailed": "創建模板失敗",
|
"createFailed": "創建模板失敗",
|
||||||
@@ -687,6 +691,8 @@
|
|||||||
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
|
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
|
||||||
"supportSell": "跟單賣出",
|
"supportSell": "跟單賣出",
|
||||||
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
|
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
|
||||||
|
"pushFilteredOrders": "推送已過濾訂單",
|
||||||
|
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
|
||||||
"invalidNumber": "請輸入有效的數字"
|
"invalidNumber": "請輸入有效的數字"
|
||||||
},
|
},
|
||||||
"copyTradingAdd": {
|
"copyTradingAdd": {
|
||||||
@@ -783,6 +789,8 @@
|
|||||||
"advancedSettings": "高級設置",
|
"advancedSettings": "高級設置",
|
||||||
"pushFailedOrders": "推送失敗訂單",
|
"pushFailedOrders": "推送失敗訂單",
|
||||||
"pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram",
|
"pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram",
|
||||||
|
"pushFilteredOrders": "推送已過濾訂單",
|
||||||
|
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
|
||||||
"autoRedeem": "自動贖回",
|
"autoRedeem": "自動贖回",
|
||||||
"autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效",
|
"autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效",
|
||||||
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
||||||
@@ -888,6 +896,8 @@
|
|||||||
"advancedSettings": "高級設置",
|
"advancedSettings": "高級設置",
|
||||||
"pushFailedOrders": "推送失敗訂單",
|
"pushFailedOrders": "推送失敗訂單",
|
||||||
"pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram",
|
"pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram",
|
||||||
|
"pushFilteredOrders": "推送已過濾訂單",
|
||||||
|
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
|
||||||
"autoRedeem": "自動贖回",
|
"autoRedeem": "自動贖回",
|
||||||
"autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效",
|
"autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效",
|
||||||
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
||||||
@@ -1008,7 +1018,7 @@
|
|||||||
"telegramConfig": {
|
"telegramConfig": {
|
||||||
"title": "Telegram 配置說明",
|
"title": "Telegram 配置說明",
|
||||||
"step1": "通過 <strong>@BotFather</strong> 創建 Telegram 機器人,獲取 Bot Token",
|
"step1": "通過 <strong>@BotFather</strong> 創建 Telegram 機器人,獲取 Bot Token",
|
||||||
"step2": "填寫 Bot Token 後,點擊\"獲取 Chat ID\"按鈕自動獲取(需要先向機器人發送消息)",
|
"step2": "填寫 Bot Token 後,點擊\"獲取 Chat ID\"按鈕自動獲取(需要先向機器人發送消息)",
|
||||||
"step3": "或通過 <strong>@userinfobot</strong> 手動獲取 Chat ID",
|
"step3": "或通過 <strong>@userinfobot</strong> 手動獲取 Chat ID",
|
||||||
"step4": "支持配置多個 Chat ID(用逗號分隔),所有配置的用戶都會收到通知",
|
"step4": "支持配置多個 Chat ID(用逗號分隔),所有配置的用戶都會收到通知",
|
||||||
"step5": "訂單成功或失敗時會自動發送 Telegram 消息",
|
"step5": "訂單成功或失敗時會自動發送 Telegram 消息",
|
||||||
@@ -1070,8 +1080,8 @@
|
|||||||
"sellStatus": "賣出狀態",
|
"sellStatus": "賣出狀態",
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"statusFilled": "已完成",
|
"statusFilled": "已完成",
|
||||||
"statusPartiallySold": "部分成交",
|
"statusPartiallySold": "部分賣出",
|
||||||
"statusFullySold": "全部成交",
|
"statusFullySold": "全部賣出",
|
||||||
"realizedPnl": "已實現盈虧",
|
"realizedPnl": "已實現盈虧",
|
||||||
"createdAt": "創建時間",
|
"createdAt": "創建時間",
|
||||||
"matchedAt": "匹配時間",
|
"matchedAt": "匹配時間",
|
||||||
@@ -1089,6 +1099,7 @@
|
|||||||
"price": "價格",
|
"price": "價格",
|
||||||
"amount": "金額",
|
"amount": "金額",
|
||||||
"filterMarketId": "篩選市場ID",
|
"filterMarketId": "篩選市場ID",
|
||||||
|
"filterMarketTitle": "篩選市場標題",
|
||||||
"filterSide": "篩選方向",
|
"filterSide": "篩選方向",
|
||||||
"filterStatus": "篩選狀態",
|
"filterStatus": "篩選狀態",
|
||||||
"filterSellOrderId": "篩選賣出訂單ID",
|
"filterSellOrderId": "篩選賣出訂單ID",
|
||||||
@@ -1099,8 +1110,9 @@
|
|||||||
"groupByMarket": "按市場分組",
|
"groupByMarket": "按市場分組",
|
||||||
"expandAll": "展開全部",
|
"expandAll": "展開全部",
|
||||||
"collapseAll": "折疊全部",
|
"collapseAll": "折疊全部",
|
||||||
"allFullyMatched": "全部成交",
|
"allFullySold": "全部賣出",
|
||||||
"partiallyMatched": "部分成交",
|
"notSold": "未賣出",
|
||||||
|
"partiallySold": "部分賣出",
|
||||||
"orderCount": "訂單數",
|
"orderCount": "訂單數",
|
||||||
"totalAmount": "總金額",
|
"totalAmount": "總金額",
|
||||||
"totalPnl": "總盈虧",
|
"totalPnl": "總盈虧",
|
||||||
@@ -1116,7 +1128,6 @@
|
|||||||
"totalMatchedOrders": "總匹配訂單數",
|
"totalMatchedOrders": "總匹配訂單數",
|
||||||
"totalBuyAmount": "總買入金額",
|
"totalBuyAmount": "總買入金額",
|
||||||
"totalSellAmount": "總賣出金額",
|
"totalSellAmount": "總賣出金額",
|
||||||
"totalPnl": "總盈虧",
|
|
||||||
"totalRealizedPnl": "總已實現盈虧",
|
"totalRealizedPnl": "總已實現盈虧",
|
||||||
"totalUnrealizedPnl": "總未實現盈虧",
|
"totalUnrealizedPnl": "總未實現盈虧",
|
||||||
"winRate": "勝率",
|
"winRate": "勝率",
|
||||||
@@ -1185,4 +1196,4 @@
|
|||||||
"providerChainstack": "Chainstack",
|
"providerChainstack": "Chainstack",
|
||||||
"providerGetBlock": "GetBlock"
|
"providerGetBlock": "GetBlock"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,8 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
||||||
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined,
|
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined,
|
||||||
maxPositionValue: (template as any).maxPositionValue ? parseFloat((template as any).maxPositionValue) : 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)
|
setCopyMode(template.copyMode)
|
||||||
setTemplateModalVisible(false)
|
setTemplateModalVisible(false)
|
||||||
@@ -255,6 +256,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
: undefined,
|
: undefined,
|
||||||
configName: values.configName?.trim(),
|
configName: values.configName?.trim(),
|
||||||
pushFailedOrders: values.pushFailedOrders ?? false,
|
pushFailedOrders: values.pushFailedOrders ?? false,
|
||||||
|
pushFilteredOrders: values.pushFilteredOrders ?? false,
|
||||||
maxMarketEndDate
|
maxMarketEndDate
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +309,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
websocketMaxRetries: 10,
|
websocketMaxRetries: 10,
|
||||||
supportSell: true,
|
supportSell: true,
|
||||||
pushFailedOrders: false,
|
pushFailedOrders: false,
|
||||||
|
pushFilteredOrders: false,
|
||||||
keywordFilterMode: 'DISABLED'
|
keywordFilterMode: 'DISABLED'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -804,12 +807,29 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
>
|
>
|
||||||
<Input.Group compact style={{ display: 'flex' }}>
|
<Input.Group compact style={{ display: 'flex' }}>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={0}
|
||||||
max={9999}
|
max={9999}
|
||||||
step={1}
|
step={1}
|
||||||
precision={0}
|
precision={0}
|
||||||
value={maxMarketEndDateValue}
|
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%' }}
|
style={{ width: '60%' }}
|
||||||
placeholder={t('copyTradingAdd.maxMarketEndDatePlaceholder') || '输入时间值(可选)'}
|
placeholder={t('copyTradingAdd.maxMarketEndDatePlaceholder') || '输入时间值(可选)'}
|
||||||
parser={(value) => {
|
parser={(value) => {
|
||||||
@@ -862,6 +882,16 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 推送已过滤订单 */}
|
||||||
|
<Form.Item
|
||||||
|
label={t('copyTradingAdd.pushFilteredOrders') || '推送已过滤订单'}
|
||||||
|
name="pushFilteredOrders"
|
||||||
|
tooltip={t('copyTradingAdd.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -25,19 +25,44 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
const [limit, setLimit] = useState(20)
|
const [limit, setLimit] = useState(20)
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
marketId?: string
|
marketId?: string
|
||||||
side?: string
|
marketTitle?: string
|
||||||
status?: 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 [expandedMarkets, setExpandedMarkets] = useState<Set<string>>(new Set()) // 展开的市场ID集合
|
||||||
const [groupedOrders, setGroupedOrders] = useState<MarketOrderGroup[]>([]) // 分组数据(从后端获取)
|
const [groupedOrders, setGroupedOrders] = useState<MarketOrderGroup[]>([]) // 分组数据(从后端获取)
|
||||||
const [groupedTotal, setGroupedTotal] = useState(0) // 分组总数(市场数量)
|
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(() => {
|
useEffect(() => {
|
||||||
if (copyTradingId && active) {
|
if (copyTradingId && active) {
|
||||||
fetchOrders()
|
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 () => {
|
const fetchOrders = async () => {
|
||||||
if (!copyTradingId) return
|
if (!copyTradingId) return
|
||||||
@@ -45,12 +70,14 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
if (groupByMarket) {
|
if (groupByMarket) {
|
||||||
// 调用分组接口
|
// 调用分组接口(支持市场筛选)
|
||||||
const request: MarketGroupedOrdersRequest = {
|
const request: MarketGroupedOrdersRequest = {
|
||||||
copyTradingId: parseInt(copyTradingId),
|
copyTradingId: parseInt(copyTradingId),
|
||||||
type: 'buy',
|
type: 'buy',
|
||||||
page,
|
page,
|
||||||
limit
|
limit,
|
||||||
|
marketId: filters.marketId,
|
||||||
|
marketTitle: filters.marketTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiService.orderTracking.listGroupedByMarket(request)
|
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',
|
title: t('copyTradingOrders.orderId') || '订单ID',
|
||||||
dataIndex: 'orderId',
|
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') || '买入数量',
|
title: t('copyTradingOrders.buyQuantity') || '买入数量',
|
||||||
dataIndex: 'quantity',
|
dataIndex: 'quantity',
|
||||||
@@ -356,10 +373,12 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{group.stats.fullyMatched ? (
|
{group.stats.fullyMatched ? (
|
||||||
<Tag color="success">{t('copyTradingOrders.allFullyMatched') || '全部成交'}</Tag>
|
<Tag color="success">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Tag>
|
||||||
|
) : group.stats.fullyMatchedCount === 0 ? (
|
||||||
|
<Tag color="default">{t('copyTradingOrders.notSold') || '未卖出'}</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag color="warning">
|
<Tag color="warning">
|
||||||
{t('copyTradingOrders.partiallyMatched') || '部分成交'} ({group.stats.fullyMatchedCount}/{group.stats.count})
|
{t('copyTradingOrders.partiallySold') || '部分卖出'} ({group.stats.fullyMatchedCount}/{group.stats.count})
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -367,10 +386,10 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
||||||
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span>
|
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span>
|
||||||
<span>
|
<span>
|
||||||
{t('copyTradingOrders.statusBreakdown') || '状态'}:
|
{t('copyTradingOrders.statusBreakdown') || '状态'}:
|
||||||
{group.stats.fullyMatchedCount > 0 && ` ${t('copyTradingOrders.statusFullySold') || '全部成交'} ${group.stats.fullyMatchedCount}`}
|
{group.stats.fullyMatchedCount > 0 && ` ${t('copyTradingOrders.allFullySold') || '全部卖出'} ${group.stats.fullyMatchedCount}`}
|
||||||
{group.stats.partiallyMatchedCount > 0 && ` ${t('copyTradingOrders.statusPartiallySold') || '部分成交'} ${group.stats.partiallyMatchedCount}`}
|
{group.stats.partiallyMatchedCount > 0 && ` ${t('copyTradingOrders.partiallySold') || '部分卖出'} ${group.stats.partiallyMatchedCount}`}
|
||||||
{group.stats.filledCount > 0 && ` ${t('copyTradingOrders.statusFilled') || '未成交'} ${group.stats.filledCount}`}
|
{group.stats.filledCount > 0 && ` ${t('copyTradingOrders.notSold') || '未卖出'} ${group.stats.filledCount}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,7 +419,6 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
})
|
})
|
||||||
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
||||||
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -436,7 +454,6 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||||
<Tag style={{ fontSize: '11px' }}>{displaySide}</Tag>
|
|
||||||
{getStatusTag(order.status)}
|
{getStatusTag(order.status)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -485,18 +502,17 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
{orders.map((order) => {
|
{orders.map((order) => {
|
||||||
const date = new Date(order.createdAt)
|
const date = new Date(order.createdAt)
|
||||||
const formattedDate = date.toLocaleString('zh-CN', {
|
const formattedDate = date.toLocaleString('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
})
|
})
|
||||||
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
||||||
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={order.orderId}
|
key={order.orderId}
|
||||||
style={{
|
style={{
|
||||||
@@ -529,7 +545,6 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||||
<Tag>{displaySide}</Tag>
|
|
||||||
{getStatusTag(order.status)}
|
{getStatusTag(order.status)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -633,23 +648,20 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'}
|
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID(支持模糊匹配)'}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: isMobile ? '100%' : 200 }}
|
style={{ width: isMobile ? '100%' : 200 }}
|
||||||
value={filters.marketId}
|
value={filters.marketId}
|
||||||
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
|
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
<Input
|
||||||
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
|
placeholder={t('copyTradingOrders.filterMarketTitle') || '筛选市场标题'}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: isMobile ? '100%' : 150 }}
|
style={{ width: isMobile ? '100%' : 200 }}
|
||||||
value={filters.side}
|
value={filters.marketTitle}
|
||||||
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
|
onChange={(e) => handleMarketTitleChange(e.target.value)}
|
||||||
>
|
/>
|
||||||
<Option value="0">YES</Option>
|
|
||||||
<Option value="1">NO</Option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
|
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
|
||||||
@@ -658,9 +670,9 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
value={filters.status}
|
value={filters.status}
|
||||||
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
||||||
>
|
>
|
||||||
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
|
<Option value="filled">{t('copyTradingOrders.notSold') || '未卖出'}</Option>
|
||||||
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallySold') || '部分成交'}</Option>
|
<Option value="partially_matched">{t('copyTradingOrders.partiallySold') || '部分卖出'}</Option>
|
||||||
<Option value="fully_matched">{t('copyTradingOrders.statusFullySold') || '全部成交'}</Option>
|
<Option value="fully_matched">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
@@ -669,7 +681,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={groupByMarket}
|
checked={groupByMarket}
|
||||||
onChange={setGroupByMarket}
|
onChange={handleToggleGroupByMarket}
|
||||||
checkedChildren={<AppstoreOutlined />}
|
checkedChildren={<AppstoreOutlined />}
|
||||||
unCheckedChildren={<UnorderedListOutlined />}
|
unCheckedChildren={<UnorderedListOutlined />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
maxPositionCount: found.maxPositionCount,
|
maxPositionCount: found.maxPositionCount,
|
||||||
keywordFilterMode: found.keywordFilterMode || 'DISABLED',
|
keywordFilterMode: found.keywordFilterMode || 'DISABLED',
|
||||||
configName: found.configName || '',
|
configName: found.configName || '',
|
||||||
pushFailedOrders: found.pushFailedOrders ?? false
|
pushFailedOrders: found.pushFailedOrders ?? false,
|
||||||
|
pushFilteredOrders: found.pushFilteredOrders ?? false
|
||||||
})
|
})
|
||||||
// 设置关键字列表
|
// 设置关键字列表
|
||||||
setKeywords(found.keywords || [])
|
setKeywords(found.keywords || [])
|
||||||
@@ -168,12 +169,17 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 计算市场截止时间(毫秒)
|
// 计算市场截止时间(毫秒)
|
||||||
|
// 如果用户清空了,传 -1 表示要清空(后端会识别并设置为 null)
|
||||||
let maxMarketEndDate: number | undefined
|
let maxMarketEndDate: number | undefined
|
||||||
if (maxMarketEndDateValue !== undefined && maxMarketEndDateValue > 0) {
|
if (maxMarketEndDateValue !== undefined && maxMarketEndDateValue !== null && maxMarketEndDateValue > 0) {
|
||||||
const multiplier = maxMarketEndDateUnit === 'HOUR'
|
const multiplier = maxMarketEndDateUnit === 'HOUR'
|
||||||
? 60 * 60 * 1000 // 小时转毫秒
|
? 60 * 60 * 1000 // 小时转毫秒
|
||||||
: 24 * 60 * 60 * 1000 // 天转毫秒
|
: 24 * 60 * 60 * 1000 // 天转毫秒
|
||||||
maxMarketEndDate = maxMarketEndDateValue * multiplier
|
maxMarketEndDate = maxMarketEndDateValue * multiplier
|
||||||
|
} else {
|
||||||
|
// 如果值为 null/undefined/0/负数,传 -1 表示要清空
|
||||||
|
// 这样无论之前是否有值,清空后都会设置为 null
|
||||||
|
maxMarketEndDate = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -195,18 +201,21 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
websocketReconnectInterval: values.websocketReconnectInterval,
|
websocketReconnectInterval: values.websocketReconnectInterval,
|
||||||
websocketMaxRetries: values.websocketMaxRetries,
|
websocketMaxRetries: values.websocketMaxRetries,
|
||||||
supportSell: values.supportSell,
|
supportSell: values.supportSell,
|
||||||
minOrderDepth: values.minOrderDepth?.toString(),
|
// 对于可选字段,始终发送(即使为空也发送空字符串,让后端知道要清空)
|
||||||
maxSpread: values.maxSpread?.toString(),
|
minOrderDepth: values.minOrderDepth != null ? values.minOrderDepth.toString() : '',
|
||||||
minPrice: values.minPrice?.toString(),
|
maxSpread: values.maxSpread != null ? values.maxSpread.toString() : '',
|
||||||
maxPrice: values.maxPrice?.toString(),
|
minPrice: values.minPrice != null ? values.minPrice.toString() : '',
|
||||||
maxPositionValue: values.maxPositionValue?.toString(),
|
maxPrice: values.maxPrice != null ? values.maxPrice.toString() : '',
|
||||||
maxPositionCount: values.maxPositionCount,
|
maxPositionValue: values.maxPositionValue != null ? values.maxPositionValue.toString() : '',
|
||||||
|
// 对于 maxPositionCount,如果值为 null/undefined,传 -1 表示要清空(后端会识别并设置为 null)
|
||||||
|
maxPositionCount: values.maxPositionCount != null ? values.maxPositionCount : -1,
|
||||||
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
|
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
|
||||||
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
|
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
|
||||||
? keywords
|
? keywords
|
||||||
: undefined,
|
: undefined,
|
||||||
configName: values.configName?.trim() || undefined,
|
configName: values.configName?.trim() || undefined,
|
||||||
pushFailedOrders: values.pushFailedOrders,
|
pushFailedOrders: values.pushFailedOrders,
|
||||||
|
pushFilteredOrders: values.pushFilteredOrders,
|
||||||
maxMarketEndDate
|
maxMarketEndDate
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,12 +731,29 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
>
|
>
|
||||||
<Input.Group compact style={{ display: 'flex' }}>
|
<Input.Group compact style={{ display: 'flex' }}>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={0}
|
||||||
max={9999}
|
max={9999}
|
||||||
step={1}
|
step={1}
|
||||||
precision={0}
|
precision={0}
|
||||||
value={maxMarketEndDateValue}
|
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%' }}
|
style={{ width: '60%' }}
|
||||||
placeholder={t('copyTradingEdit.maxMarketEndDatePlaceholder') || '输入时间值(可选)'}
|
placeholder={t('copyTradingEdit.maxMarketEndDatePlaceholder') || '输入时间值(可选)'}
|
||||||
parser={(value) => {
|
parser={(value) => {
|
||||||
@@ -778,6 +804,15 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('copyTradingEdit.pushFilteredOrders') || '推送已过滤订单'}
|
||||||
|
name="pushFilteredOrders"
|
||||||
|
tooltip={t('copyTradingEdit.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Table, Input, Button, Card, Divider, Spin, message } from 'antd'
|
import { Table, Input, Button, Card, Divider, Spin, message } from 'antd'
|
||||||
import { apiService } from '../../services/api'
|
import { apiService } from '../../services/api'
|
||||||
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard } from '../../utils'
|
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard, getPolymarketUrl } from '../../utils'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
|
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
|
||||||
@@ -24,13 +24,29 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
sellOrderId?: string
|
sellOrderId?: string
|
||||||
buyOrderId?: string
|
buyOrderId?: string
|
||||||
|
marketTitle?: string
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
|
const handleMarketTitleChange = (value: string) => {
|
||||||
|
setFilters({ ...filters, marketTitle: value || undefined })
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (copyTradingId && active) {
|
if (copyTradingId && active) {
|
||||||
fetchOrders()
|
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 () => {
|
const fetchOrders = async () => {
|
||||||
if (!copyTradingId) return
|
if (!copyTradingId) return
|
||||||
@@ -75,59 +91,101 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: t('copyTradingOrders.sellOrderId') || '卖出订单ID',
|
title: t('copyTradingOrders.market') || '市场',
|
||||||
dataIndex: 'sellOrderId',
|
dataIndex: 'marketId',
|
||||||
key: 'sellOrderId',
|
key: 'marketId',
|
||||||
width: isMobile ? 120 : 180,
|
width: isMobile ? 120 : 200,
|
||||||
render: (text: string) => {
|
render: (text: string, record: MatchedOrderInfo) => {
|
||||||
const isAuto = isAutoGeneratedOrderId(text)
|
const marketUrl = getPolymarketUrl(record.marketSlug, record.eventSlug, record.marketCategory, record.marketId)
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
{record.marketTitle ? (
|
||||||
|
marketUrl ? (
|
||||||
|
<a
|
||||||
|
href={marketUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? 11 : 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#1890ff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{record.marketTitle}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: isMobile ? 11 : 12, fontWeight: 500 }}>
|
||||||
|
{record.marketTitle}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 10 : 11, color: '#999' }}>
|
||||||
{isMobile
|
{isMobile
|
||||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
{!isAuto && (
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => handleCopyOrderId(text)}
|
|
||||||
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
|
||||||
title={t('common.copy') || '复制'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('copyTradingOrders.buyOrderId') || '买入订单ID',
|
title: t('copyTradingOrders.orderId') || '订单ID',
|
||||||
dataIndex: 'buyOrderId',
|
dataIndex: 'orderId',
|
||||||
key: 'buyOrderId',
|
key: 'orderId',
|
||||||
width: isMobile ? 120 : 180,
|
width: isMobile ? 150 : 200,
|
||||||
render: (text: string) => {
|
render: (_: any, record: MatchedOrderInfo) => {
|
||||||
const isAuto = isAutoGeneratedOrderId(text)
|
const buyOrderId = record.buyOrderId
|
||||||
|
const sellOrderId = record.sellOrderId
|
||||||
|
const isBuyAuto = isAutoGeneratedOrderId(buyOrderId)
|
||||||
|
const isSellAuto = isAutoGeneratedOrderId(sellOrderId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||||
{isMobile
|
<span style={{ fontSize: isMobile ? 11 : 12, color: '#999' }}>
|
||||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
{t('copyTradingOrders.buy') || '买入'}:
|
||||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
</span>
|
||||||
}
|
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||||
</span>
|
{isMobile
|
||||||
{!isAuto && (
|
? `${buyOrderId.slice(0, 6)}...${buyOrderId.slice(-4)}`
|
||||||
<Button
|
: `${buyOrderId.slice(0, 8)}...${buyOrderId.slice(-6)}`
|
||||||
type="text"
|
}
|
||||||
size="small"
|
</span>
|
||||||
icon={<CopyOutlined />}
|
{!isBuyAuto && (
|
||||||
onClick={() => handleCopyOrderId(text)}
|
<Button
|
||||||
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
type="text"
|
||||||
title={t('common.copy') || '复制'}
|
size="small"
|
||||||
/>
|
icon={<CopyOutlined />}
|
||||||
)}
|
onClick={() => handleCopyOrderId(buyOrderId)}
|
||||||
|
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
||||||
|
title={t('common.copy') || '复制'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: isMobile ? 11 : 12, color: '#999' }}>
|
||||||
|
{t('copyTradingOrders.sell') || '卖出'}:
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||||
|
{isMobile
|
||||||
|
? `${sellOrderId.slice(0, 6)}...${sellOrderId.slice(-4)}`
|
||||||
|
: `${sellOrderId.slice(0, 8)}...${sellOrderId.slice(-6)}`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{!isSellAuto && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => handleCopyOrderId(sellOrderId)}
|
||||||
|
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
||||||
|
title={t('common.copy') || '复制'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -193,6 +251,14 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
<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
|
<Input
|
||||||
placeholder={t('copyTradingOrders.filterSellOrderId') || '筛选卖出订单ID'}
|
placeholder={t('copyTradingOrders.filterSellOrderId') || '筛选卖出订单ID'}
|
||||||
allowClear
|
allowClear
|
||||||
@@ -209,7 +275,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
|
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.search') || '查询'}</Button>
|
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.refresh') || '刷新'}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
@@ -249,9 +315,31 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
|
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
|
||||||
{order.marketTitle ? (
|
{order.marketTitle ? (
|
||||||
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
|
(() => {
|
||||||
{order.marketTitle}
|
const marketUrl = getPolymarketUrl(order.marketSlug, order.eventSlug, order.marketCategory, order.marketId)
|
||||||
</div>
|
return marketUrl ? (
|
||||||
|
<a
|
||||||
|
href={marketUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: '4px',
|
||||||
|
color: '#1890ff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'block'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{order.marketTitle}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
|
||||||
|
{order.marketTitle}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
) : null}
|
) : null}
|
||||||
{order.marketId && (
|
{order.marketId && (
|
||||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||||
@@ -260,50 +348,42 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellOrderId') || '卖出订单ID'}</div>
|
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.orderId') || '订单ID'}</div>
|
||||||
<div style={{
|
<div style={{ marginBottom: '8px' }}>
|
||||||
fontSize: '13px',
|
<div style={{ marginBottom: '4px' }}>
|
||||||
fontWeight: '500',
|
<div style={{ fontSize: '11px', color: '#999', marginBottom: '2px' }}>{t('copyTradingOrders.buy') || '买入'}:</div>
|
||||||
fontFamily: 'monospace',
|
<div style={{ fontSize: '13px', fontWeight: '500', fontFamily: 'monospace', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
marginBottom: '8px',
|
<span>{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}</span>
|
||||||
display: 'flex',
|
{!isAutoGeneratedOrderId(order.buyOrderId) && (
|
||||||
alignItems: 'center',
|
<Button
|
||||||
gap: '8px'
|
type="text"
|
||||||
}}>
|
size="small"
|
||||||
<span>{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}</span>
|
icon={<CopyOutlined />}
|
||||||
{!isAutoGeneratedOrderId(order.sellOrderId) && (
|
onClick={() => handleCopyOrderId(order.buyOrderId)}
|
||||||
<Button
|
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
||||||
type="text"
|
title={t('common.copy') || '复制'}
|
||||||
size="small"
|
/>
|
||||||
icon={<CopyOutlined />}
|
)}
|
||||||
onClick={() => handleCopyOrderId(order.sellOrderId)}
|
</div>
|
||||||
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
|
||||||
title={t('common.copy') || '复制'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyOrderId') || '买入订单ID'}</div>
|
<div>
|
||||||
<div style={{
|
<div style={{ fontSize: '11px', color: '#999', marginBottom: '2px' }}>{t('copyTradingOrders.sell') || '卖出'}:</div>
|
||||||
fontSize: '13px',
|
<div style={{ fontSize: '13px', fontWeight: '500', fontFamily: 'monospace', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
fontWeight: '500',
|
<span>{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}</span>
|
||||||
fontFamily: 'monospace',
|
{!isAutoGeneratedOrderId(order.sellOrderId) && (
|
||||||
display: 'flex',
|
<Button
|
||||||
alignItems: 'center',
|
type="text"
|
||||||
gap: '8px'
|
size="small"
|
||||||
}}>
|
icon={<CopyOutlined />}
|
||||||
<span>{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}</span>
|
onClick={() => handleCopyOrderId(order.sellOrderId)}
|
||||||
{!isAutoGeneratedOrderId(order.buyOrderId) && (
|
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
||||||
<Button
|
title={t('common.copy') || '复制'}
|
||||||
type="text"
|
/>
|
||||||
size="small"
|
)}
|
||||||
icon={<CopyOutlined />}
|
</div>
|
||||||
onClick={() => handleCopyOrderId(order.buyOrderId)}
|
|
||||||
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
|
||||||
title={t('common.copy') || '复制'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
|
|||||||
@@ -25,18 +25,44 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
|||||||
const [limit, setLimit] = useState(20)
|
const [limit, setLimit] = useState(20)
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
marketId?: string
|
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 [expandedMarkets, setExpandedMarkets] = useState<Set<string>>(new Set()) // 展开的市场ID集合
|
||||||
const [groupedOrders, setGroupedOrders] = useState<MarketOrderGroup[]>([]) // 分组数据(从后端获取)
|
const [groupedOrders, setGroupedOrders] = useState<MarketOrderGroup[]>([]) // 分组数据(从后端获取)
|
||||||
const [groupedTotal, setGroupedTotal] = useState(0) // 分组总数(市场数量)
|
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(() => {
|
useEffect(() => {
|
||||||
if (copyTradingId && active) {
|
if (copyTradingId && active) {
|
||||||
fetchOrders()
|
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 () => {
|
const fetchOrders = async () => {
|
||||||
if (!copyTradingId) return
|
if (!copyTradingId) return
|
||||||
@@ -49,7 +75,9 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
|||||||
copyTradingId: parseInt(copyTradingId),
|
copyTradingId: parseInt(copyTradingId),
|
||||||
type: 'sell',
|
type: 'sell',
|
||||||
page,
|
page,
|
||||||
limit
|
limit,
|
||||||
|
marketId: filters.marketId,
|
||||||
|
marketTitle: filters.marketTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiService.orderTracking.listGroupedByMarket(request)
|
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') || '卖出数量',
|
title: t('copyTradingOrders.sellQuantity') || '卖出数量',
|
||||||
dataIndex: 'quantity',
|
dataIndex: 'quantity',
|
||||||
@@ -340,7 +358,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
|||||||
{marketDisplayName}
|
{marketDisplayName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Tag color="success">{t('copyTradingOrders.allFullyMatched') || '全部成交'}</Tag>
|
<Tag color="success">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}>
|
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}>
|
||||||
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
||||||
@@ -378,7 +396,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
})
|
})
|
||||||
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
||||||
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -413,9 +430,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
|
||||||
<Tag style={{ fontSize: '11px' }}>{displaySide}</Tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||||
@@ -473,7 +487,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
})
|
})
|
||||||
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
||||||
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -507,9 +520,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
|
||||||
<Tag>{displaySide}</Tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
<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 })}
|
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
|
<Select
|
||||||
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
|
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: isMobile ? '100%' : 150 }}
|
style={{ width: isMobile ? '100%' : 150 }}
|
||||||
value={filters.side}
|
value={filters.status}
|
||||||
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
|
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
||||||
>
|
>
|
||||||
<Option value="0">YES</Option>
|
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
|
||||||
<Option value="1">NO</Option>
|
<Option value="partially_matched">{t('copyTradingOrders.partiallySold') || '部分卖出'}</Option>
|
||||||
<Option value="YES">YES</Option>
|
<Option value="fully_matched">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Option>
|
||||||
<Option value="NO">NO</Option>
|
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
@@ -641,7 +658,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
|||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={groupByMarket}
|
checked={groupByMarket}
|
||||||
onChange={setGroupByMarket}
|
onChange={handleToggleGroupByMarket}
|
||||||
checkedChildren={<AppstoreOutlined />}
|
checkedChildren={<AppstoreOutlined />}
|
||||||
unCheckedChildren={<UnorderedListOutlined />}
|
unCheckedChildren={<UnorderedListOutlined />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const PositionList: React.FC = () => {
|
|||||||
const [redeemableSummary, setRedeemableSummary] = useState<RedeemablePositionsSummary | null>(null)
|
const [redeemableSummary, setRedeemableSummary] = useState<RedeemablePositionsSummary | null>(null)
|
||||||
const [loadingRedeemableSummary, setLoadingRedeemableSummary] = useState(false)
|
const [loadingRedeemableSummary, setLoadingRedeemableSummary] = useState(false)
|
||||||
const [redeeming, setRedeeming] = useState(false)
|
const [redeeming, setRedeeming] = useState(false)
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAccounts()
|
fetchAccounts()
|
||||||
@@ -66,6 +68,11 @@ const PositionList: React.FC = () => {
|
|||||||
fetchRedeemableSummary()
|
fetchRedeemableSummary()
|
||||||
}
|
}
|
||||||
}, [currentPositions, selectedAccountId])
|
}, [currentPositions, selectedAccountId])
|
||||||
|
|
||||||
|
// 当筛选条件或搜索关键词变化时,重置分页到第一页
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1)
|
||||||
|
}, [positionFilter, selectedAccountId, searchKeyword])
|
||||||
|
|
||||||
// 获取可赎回仓位统计
|
// 获取可赎回仓位统计
|
||||||
const fetchRedeemableSummary = async () => {
|
const fetchRedeemableSummary = async () => {
|
||||||
@@ -265,12 +272,12 @@ const PositionList: React.FC = () => {
|
|||||||
// 本地搜索和筛选过滤
|
// 本地搜索和筛选过滤
|
||||||
const filteredPositions = useMemo(() => {
|
const filteredPositions = useMemo(() => {
|
||||||
let filtered = basePositions
|
let filtered = basePositions
|
||||||
|
|
||||||
// 1. 先按账户筛选
|
// 1. 先按账户筛选
|
||||||
if (selectedAccountId !== undefined) {
|
if (selectedAccountId !== undefined) {
|
||||||
filtered = filtered.filter(p => p.accountId === selectedAccountId)
|
filtered = filtered.filter(p => p.accountId === selectedAccountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 最后按关键词搜索
|
// 2. 最后按关键词搜索
|
||||||
if (searchKeyword.trim()) {
|
if (searchKeyword.trim()) {
|
||||||
const keyword = searchKeyword.trim().toLowerCase()
|
const keyword = searchKeyword.trim().toLowerCase()
|
||||||
@@ -302,9 +309,16 @@ const PositionList: React.FC = () => {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
}, [basePositions, searchKeyword, selectedAccountId])
|
}, [basePositions, searchKeyword, selectedAccountId])
|
||||||
|
|
||||||
|
// 分页后的数据
|
||||||
|
const paginatedPositions = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * pageSize
|
||||||
|
const endIndex = startIndex + pageSize
|
||||||
|
return filteredPositions.slice(startIndex, endIndex)
|
||||||
|
}, [filteredPositions, currentPage, pageSize])
|
||||||
|
|
||||||
const getSideColor = (side: string) => {
|
const getSideColor = (side: string) => {
|
||||||
return side === 'YES' ? 'green' : 'red'
|
return side === 'YES' ? 'green' : 'red'
|
||||||
@@ -349,14 +363,32 @@ const PositionList: React.FC = () => {
|
|||||||
if (!isNaN(initialValue)) {
|
if (!isNaN(initialValue)) {
|
||||||
totalInitialValue += initialValue
|
totalInitialValue += initialValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 当前仓位:统计持仓价值
|
||||||
|
// 历史仓位:currentValue 应该为 0(已平仓)
|
||||||
if (!isNaN(currentValue)) {
|
if (!isNaN(currentValue)) {
|
||||||
totalCurrentValue += currentValue
|
totalCurrentValue += currentValue
|
||||||
}
|
}
|
||||||
if (!isNaN(pnl)) {
|
|
||||||
totalPnl += pnl
|
// 对于当前仓位:
|
||||||
}
|
// - pnl:未实现盈亏(浮动盈亏)
|
||||||
if (!isNaN(realizedPnl)) {
|
// - realizedPnl:已实现盈亏(部分平仓时产生)
|
||||||
totalRealizedPnl += realizedPnl
|
// 对于历史仓位:
|
||||||
|
// - pnl:总已实现盈亏(包含部分平仓 + 完全平仓)
|
||||||
|
// - realizedPnl:部分平仓的已实现盈亏(可能与 pnl 重复)
|
||||||
|
if (pos.isCurrent) {
|
||||||
|
// 当前仓位:未实现盈亏 + 已实现盈亏
|
||||||
|
if (!isNaN(pnl)) {
|
||||||
|
totalPnl += pnl
|
||||||
|
}
|
||||||
|
if (!isNaN(realizedPnl)) {
|
||||||
|
totalRealizedPnl += realizedPnl
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 历史仓位:pnl 是总已实现盈亏,realizedPnl 可能重复,所以只统计 pnl
|
||||||
|
if (!isNaN(pnl)) {
|
||||||
|
totalRealizedPnl += pnl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -516,10 +548,10 @@ const PositionList: React.FC = () => {
|
|||||||
|
|
||||||
// 渲染卡片视图
|
// 渲染卡片视图
|
||||||
const renderCardView = () => {
|
const renderCardView = () => {
|
||||||
if (filteredPositions.length === 0) {
|
if (paginatedPositions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Empty
|
<Empty
|
||||||
description="暂无仓位数据"
|
description="暂无仓位数据"
|
||||||
style={{ padding: '60px 0' }}
|
style={{ padding: '60px 0' }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -527,7 +559,7 @@ const PositionList: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{filteredPositions.map((position, index) => {
|
{paginatedPositions.map((position, index) => {
|
||||||
const pnlNum = parseFloat(position.pnl || '0')
|
const pnlNum = parseFloat(position.pnl || '0')
|
||||||
const isProfit = pnlNum >= 0
|
const isProfit = pnlNum >= 0
|
||||||
// 只有当前仓位才根据盈亏显示边框颜色
|
// 只有当前仓位才根据盈亏显示边框颜色
|
||||||
@@ -1235,8 +1267,8 @@ const PositionList: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(基于当前筛选后的仓位) */}
|
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(仅当前仓位显示) */}
|
||||||
{filteredPositions.length > 0 && (
|
{filteredPositions.length > 0 && positionFilter === 'current' && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: '12px',
|
marginTop: '12px',
|
||||||
@@ -1259,13 +1291,11 @@ const PositionList: React.FC = () => {
|
|||||||
<span>
|
<span>
|
||||||
当前价值合计:{' '}
|
当前价值合计:{' '}
|
||||||
<span style={{ fontWeight: 600 }}>
|
<span style={{ fontWeight: 600 }}>
|
||||||
{positionFilter === 'current'
|
{formatUSDC(positionTotals.totalCurrentValue.toString())} USDC
|
||||||
? `${formatUSDC(positionTotals.totalCurrentValue.toString())} USDC`
|
|
||||||
: '-'}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
盈亏合计:{' '}
|
浮动盈亏合计:{' '}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
@@ -1295,32 +1325,87 @@ const PositionList: React.FC = () => {
|
|||||||
{(isMobile || viewMode === 'card') ? (
|
{(isMobile || viewMode === 'card') ? (
|
||||||
<Card loading={loading}>
|
<Card loading={loading}>
|
||||||
{renderCardView()}
|
{renderCardView()}
|
||||||
|
{/* 移动端分页 */}
|
||||||
{filteredPositions.length > 0 && (
|
{filteredPositions.length > 0 && (
|
||||||
<div style={{
|
<>
|
||||||
marginTop: '24px',
|
<div style={{
|
||||||
textAlign: 'center',
|
marginTop: '16px',
|
||||||
color: '#999',
|
display: 'flex',
|
||||||
fontSize: '14px'
|
justifyContent: 'space-between',
|
||||||
}}>
|
alignItems: 'center',
|
||||||
共 {filteredPositions.length} 个仓位{searchKeyword ? `(已过滤)` : ''}
|
flexWrap: 'wrap',
|
||||||
</div>
|
gap: '8px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
共 {filteredPositions.length} 个仓位{searchKeyword ? `(已过滤)` : ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setCurrentPage(currentPage - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span style={{ lineHeight: '32px', fontSize: '14px' }}>
|
||||||
|
{currentPage} / {Math.ceil(filteredPositions.length / pageSize)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={currentPage >= Math.ceil(filteredPositions.length / pageSize)}
|
||||||
|
onClick={() => setCurrentPage(currentPage + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 每页条数选择器 */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
textAlign: 'right',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}>
|
||||||
|
<Select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(value) => {
|
||||||
|
setPageSize(value)
|
||||||
|
setCurrentPage(1)
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
style={{ width: '100px' }}
|
||||||
|
>
|
||||||
|
<Select.Option value={10}>10 条/页</Select.Option>
|
||||||
|
<Select.Option value={20}>20 条/页</Select.Option>
|
||||||
|
<Select.Option value={50}>50 条/页</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<Table
|
<Table
|
||||||
dataSource={filteredPositions}
|
dataSource={filteredPositions}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={{
|
||||||
pageSize: 20,
|
current: currentPage,
|
||||||
showSizeChanger: !isMobile,
|
pageSize: pageSize,
|
||||||
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`
|
total: filteredPositions.length,
|
||||||
}}
|
showSizeChanger: true,
|
||||||
scroll={isMobile ? { x: 1500 } : undefined}
|
pageSizeOptions: ['10', '20', '50'],
|
||||||
/>
|
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`,
|
||||||
</Card>
|
onChange: (page, size) => {
|
||||||
|
setCurrentPage(page)
|
||||||
|
if (size !== pageSize) {
|
||||||
|
setPageSize(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
scroll={isMobile ? { x: 1500 } : undefined}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 出售模态框 */}
|
{/* 出售模态框 */}
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ const TemplateAdd: React.FC = () => {
|
|||||||
minOrderDepth: values.minOrderDepth?.toString(),
|
minOrderDepth: values.minOrderDepth?.toString(),
|
||||||
maxSpread: values.maxSpread?.toString(),
|
maxSpread: values.maxSpread?.toString(),
|
||||||
minPrice: values.minPrice?.toString(),
|
minPrice: values.minPrice?.toString(),
|
||||||
maxPrice: values.maxPrice?.toString()
|
maxPrice: values.maxPrice?.toString(),
|
||||||
|
pushFilteredOrders: values.pushFilteredOrders ?? false
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.data.code === 0) {
|
if (response.data.code === 0) {
|
||||||
@@ -96,7 +97,8 @@ const TemplateAdd: React.FC = () => {
|
|||||||
minOrderSize: 1,
|
minOrderSize: 1,
|
||||||
maxDailyOrders: 100,
|
maxDailyOrders: 100,
|
||||||
priceTolerance: 5,
|
priceTolerance: 5,
|
||||||
supportSell: true
|
supportSell: true,
|
||||||
|
pushFilteredOrders: false
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -373,6 +375,15 @@ const TemplateAdd: React.FC = () => {
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('templateAdd.pushFilteredOrders') || '推送已过滤订单'}
|
||||||
|
name="pushFilteredOrders"
|
||||||
|
tooltip={t('templateAdd.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item shouldUpdate>
|
<Form.Item shouldUpdate>
|
||||||
{({ getFieldsError }) => {
|
{({ getFieldsError }) => {
|
||||||
const errors = getFieldsError()
|
const errors = getFieldsError()
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ const TemplateEdit: React.FC = () => {
|
|||||||
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
|
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
|
||||||
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
|
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
|
||||||
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
||||||
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined
|
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined,
|
||||||
|
pushFilteredOrders: template.pushFilteredOrders ?? false
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
message.error(response.data.msg || t('templateEdit.fetchFailed') || '获取模板详情失败')
|
message.error(response.data.msg || t('templateEdit.fetchFailed') || '获取模板详情失败')
|
||||||
@@ -99,7 +100,8 @@ const TemplateEdit: React.FC = () => {
|
|||||||
minOrderDepth: values.minOrderDepth?.toString(),
|
minOrderDepth: values.minOrderDepth?.toString(),
|
||||||
maxSpread: values.maxSpread?.toString(),
|
maxSpread: values.maxSpread?.toString(),
|
||||||
minPrice: values.minPrice?.toString(),
|
minPrice: values.minPrice?.toString(),
|
||||||
maxPrice: values.maxPrice?.toString()
|
maxPrice: values.maxPrice?.toString(),
|
||||||
|
pushFilteredOrders: values.pushFilteredOrders
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.data.code === 0) {
|
if (response.data.code === 0) {
|
||||||
@@ -409,6 +411,15 @@ const TemplateEdit: React.FC = () => {
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('templateEdit.pushFilteredOrders') || '推送已过滤订单'}
|
||||||
|
name="pushFilteredOrders"
|
||||||
|
tooltip={t('templateEdit.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item shouldUpdate>
|
<Form.Item shouldUpdate>
|
||||||
{({ getFieldsError }) => {
|
{({ getFieldsError }) => {
|
||||||
const errors = getFieldsError()
|
const errors = getFieldsError()
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const TemplateList: React.FC = () => {
|
|||||||
maxDailyOrders: template.maxDailyOrders,
|
maxDailyOrders: template.maxDailyOrders,
|
||||||
priceTolerance: parseFloat(template.priceTolerance),
|
priceTolerance: parseFloat(template.priceTolerance),
|
||||||
supportSell: template.supportSell,
|
supportSell: template.supportSell,
|
||||||
|
pushFilteredOrders: template.pushFilteredOrders ?? false,
|
||||||
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
|
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
|
||||||
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
|
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
|
||||||
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
||||||
@@ -122,7 +123,8 @@ const TemplateList: React.FC = () => {
|
|||||||
minOrderDepth: values.minOrderDepth?.toString(),
|
minOrderDepth: values.minOrderDepth?.toString(),
|
||||||
maxSpread: values.maxSpread?.toString(),
|
maxSpread: values.maxSpread?.toString(),
|
||||||
minPrice: values.minPrice?.toString(),
|
minPrice: values.minPrice?.toString(),
|
||||||
maxPrice: values.maxPrice?.toString()
|
maxPrice: values.maxPrice?.toString(),
|
||||||
|
pushFilteredOrders: values.pushFilteredOrders ?? false
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.data.code === 0) {
|
if (response.data.code === 0) {
|
||||||
@@ -631,6 +633,15 @@ const TemplateList: React.FC = () => {
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('templateList.pushFilteredOrders') || '推送已过滤订单'}
|
||||||
|
name="pushFilteredOrders"
|
||||||
|
tooltip={t('templateList.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Divider>过滤条件(可选)</Divider>
|
<Divider>过滤条件(可选)</Divider>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export interface CopyTradingTemplate {
|
|||||||
maxSpread?: string
|
maxSpread?: string
|
||||||
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
|
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
|
||||||
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
pushFilteredOrders?: boolean // 推送已过滤订单(默认关闭)
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
@@ -266,6 +267,7 @@ export interface CopyTradingCreateRequest {
|
|||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
configName?: string // 配置名(可选,但提供时必须非空)
|
configName?: string // 配置名(可选,但提供时必须非空)
|
||||||
pushFailedOrders?: boolean // 推送失败订单(可选)
|
pushFailedOrders?: boolean // 推送失败订单(可选)
|
||||||
|
pushFilteredOrders?: boolean // 推送已过滤订单(可选)
|
||||||
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +306,7 @@ export interface CopyTradingUpdateRequest {
|
|||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
configName?: string // 配置名(可选,但提供时必须非空)
|
configName?: string // 配置名(可选,但提供时必须非空)
|
||||||
pushFailedOrders?: boolean // 推送失败订单(可选)
|
pushFailedOrders?: boolean // 推送失败订单(可选)
|
||||||
|
pushFilteredOrders?: boolean // 推送已过滤订单(可选)
|
||||||
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,6 +673,7 @@ export interface SellOrderInfo {
|
|||||||
price: string
|
price: string
|
||||||
amount: string
|
amount: string
|
||||||
realizedPnl: string
|
realizedPnl: string
|
||||||
|
status?: string // 卖出状态(filled, partially_matched, fully_matched)
|
||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,7 +714,7 @@ export interface OrderTrackingRequest {
|
|||||||
page?: number
|
page?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
marketId?: string
|
marketId?: string
|
||||||
side?: string
|
marketTitle?: string
|
||||||
status?: string
|
status?: string
|
||||||
sellOrderId?: string
|
sellOrderId?: string
|
||||||
buyOrderId?: string
|
buyOrderId?: string
|
||||||
@@ -724,6 +728,8 @@ export interface MarketGroupedOrdersRequest {
|
|||||||
type: 'buy' | 'sell'
|
type: 'buy' | 'sell'
|
||||||
page?: number
|
page?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
|
marketId?: string
|
||||||
|
marketTitle?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取订单详情脚本
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* node scripts/get-order-detail.js <private_key> <order_id>
|
||||||
|
*
|
||||||
|
* 参数说明:
|
||||||
|
* private_key: 钱包私钥(用于签名)
|
||||||
|
* order_id: 订单 ID
|
||||||
|
*
|
||||||
|
* 示例:
|
||||||
|
* node scripts/get-order-detail.js "0x..." "0x123..."
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Wallet } from '@ethersproject/wallet';
|
||||||
|
import { ClobClient } from '@polymarket/clob-client';
|
||||||
|
|
||||||
|
// Polymarket CLOB 主机地址
|
||||||
|
const HOST = 'https://clob.polymarket.com';
|
||||||
|
const CHAIN_ID = 137; // Polygon 主网
|
||||||
|
|
||||||
|
async function getOrderDetail(privateKey, orderId) {
|
||||||
|
try {
|
||||||
|
console.log('正在初始化钱包...');
|
||||||
|
const wallet = new Wallet(privateKey);
|
||||||
|
console.log(`钱包地址: ${wallet.address}`);
|
||||||
|
|
||||||
|
console.log('\n正在初始化 ClobClient...');
|
||||||
|
const clobClient = new ClobClient(
|
||||||
|
HOST,
|
||||||
|
CHAIN_ID,
|
||||||
|
wallet
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\n正在获取或创建 API Key...');
|
||||||
|
try {
|
||||||
|
// 尝试 derive API key(如果已存在)
|
||||||
|
const creds = await clobClient.deriveApiKey();
|
||||||
|
console.log(`✅ API Key 已获取`);
|
||||||
|
console.log(` API Key: ${creds.key.substring(0, 10)}...`);
|
||||||
|
console.log(` Passphrase: ${creds.passphrase.substring(0, 10)}...`);
|
||||||
|
|
||||||
|
// 使用 creds 初始化一个新的 ClobClient 实例用于 L2 认证
|
||||||
|
const authenticatedClient = new ClobClient(
|
||||||
|
HOST,
|
||||||
|
CHAIN_ID,
|
||||||
|
wallet,
|
||||||
|
creds
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n正在获取订单详情...`);
|
||||||
|
console.log(` 订单 ID: ${orderId}`);
|
||||||
|
|
||||||
|
const orderDetail = await authenticatedClient.getOrder(orderId);
|
||||||
|
|
||||||
|
console.log('\n================ 订单详情 ================');
|
||||||
|
console.log(`订单 ID: ${orderDetail.id}`);
|
||||||
|
console.log(`状态: ${orderDetail.status}`);
|
||||||
|
console.log(`所有者: ${orderDetail.owner}`);
|
||||||
|
console.log(`Maker 地址: ${orderDetail.maker_address}`);
|
||||||
|
console.log(`市场 ID: ${orderDetail.market}`);
|
||||||
|
console.log(`资产 ID: ${orderDetail.asset_id}`);
|
||||||
|
console.log(`方向: ${orderDetail.side}`);
|
||||||
|
console.log(`原始数量: ${orderDetail.original_size}`);
|
||||||
|
console.log(`已匹配数量: ${orderDetail.size_matched}`);
|
||||||
|
console.log(`价格: ${orderDetail.price}`);
|
||||||
|
console.log(`结果: ${orderDetail.outcome}`);
|
||||||
|
console.log(`创建时间: ${new Date(orderDetail.created_at * 1000).toISOString()}`);
|
||||||
|
console.log(`过期时间: ${orderDetail.expiration}`);
|
||||||
|
console.log(`订单类型: ${orderDetail.order_type}`);
|
||||||
|
|
||||||
|
if (orderDetail.associate_trades && orderDetail.associate_trades.length > 0) {
|
||||||
|
console.log(`关联交易数量: ${orderDetail.associate_trades.length}`);
|
||||||
|
console.log(`关联交易 IDs: ${orderDetail.associate_trades.join(', ')}`);
|
||||||
|
}
|
||||||
|
console.log('=========================================\n');
|
||||||
|
|
||||||
|
} catch (apiKeyError) {
|
||||||
|
if (apiKeyError.message && apiKeyError.message.includes('API key does not exist')) {
|
||||||
|
console.log('⚠️ API Key 不存在,正在创建新的 API Key...');
|
||||||
|
|
||||||
|
// 创建新的 API key
|
||||||
|
const creds = await clobClient.createApiKey();
|
||||||
|
console.log(`✅ API Key 已创建`);
|
||||||
|
console.log(` API Key: ${creds.key.substring(0, 10)}...`);
|
||||||
|
console.log(` Passphrase: ${creds.passphrase.substring(0, 10)}...`);
|
||||||
|
|
||||||
|
// 使用 creds 初始化一个新的 ClobClient 实例用于 L2 认证
|
||||||
|
const authenticatedClient = new ClobClient(
|
||||||
|
HOST,
|
||||||
|
CHAIN_ID,
|
||||||
|
wallet,
|
||||||
|
creds
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n正在获取订单详情...`);
|
||||||
|
console.log(` 订单 ID: ${orderId}`);
|
||||||
|
|
||||||
|
const orderDetail = await authenticatedClient.getOrder(orderId);
|
||||||
|
|
||||||
|
console.log('\n================ 订单详情 ================');
|
||||||
|
console.log(`订单 ID: ${orderDetail.id}`);
|
||||||
|
console.log(`状态: ${orderDetail.status}`);
|
||||||
|
console.log(`所有者: ${orderDetail.owner}`);
|
||||||
|
console.log(`Maker 地址: ${orderDetail.maker_address}`);
|
||||||
|
console.log(`市场 ID: ${orderDetail.market}`);
|
||||||
|
console.log(`资产 ID: ${orderDetail.asset_id}`);
|
||||||
|
console.log(`方向: ${orderDetail.side}`);
|
||||||
|
console.log(`原始数量: ${orderDetail.original_size}`);
|
||||||
|
console.log(`已匹配数量: ${orderDetail.size_matched}`);
|
||||||
|
console.log(`价格: ${orderDetail.price}`);
|
||||||
|
console.log(`结果: ${orderDetail.outcome}`);
|
||||||
|
console.log(`创建时间: ${new Date(orderDetail.created_at * 1000).toISOString()}`);
|
||||||
|
console.log(`过期时间: ${orderDetail.expiration}`);
|
||||||
|
console.log(`订单类型: ${orderDetail.order_type}`);
|
||||||
|
|
||||||
|
if (orderDetail.associate_trades && orderDetail.associate_trades.length > 0) {
|
||||||
|
console.log(`关联交易数量: ${orderDetail.associate_trades.length}`);
|
||||||
|
console.log(`关联交易 IDs: ${orderDetail.associate_trades.join(', ')}`);
|
||||||
|
}
|
||||||
|
console.log('=========================================\n');
|
||||||
|
} else {
|
||||||
|
throw apiKeyError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ 获取订单详情失败:');
|
||||||
|
console.error(error.message);
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
console.error(`\nHTTP 状态码: ${error.response.status}`);
|
||||||
|
console.error(`响应数据:`, error.response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查命令行参数
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.length < 2) {
|
||||||
|
console.error('错误: 缺少必要参数');
|
||||||
|
console.error('\n使用方法:');
|
||||||
|
console.error(' node scripts/get-order-detail.js <private_key> <order_id>');
|
||||||
|
console.error('\n参数说明:');
|
||||||
|
console.error(' private_key: 钱包私钥(用于签名)');
|
||||||
|
console.error(' order_id: 订单 ID');
|
||||||
|
console.error('\n示例:');
|
||||||
|
console.error(' node scripts/get-order-detail.js "0x123..." "0x456..."');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [privateKey, orderId] = args;
|
||||||
|
|
||||||
|
// 验证私钥格式
|
||||||
|
if (!privateKey.startsWith('0x')) {
|
||||||
|
console.error('错误: 私钥格式不正确,必须以 0x 开头');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (privateKey.length !== 66) {
|
||||||
|
console.error('错误: 私钥长度不正确,应为 66 个字符(包括 0x 前缀)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证订单 ID
|
||||||
|
if (!orderId.startsWith('0x')) {
|
||||||
|
console.error('错误: 订单 ID 格式不正确,必须以 0x 开头');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行主函数
|
||||||
|
getOrderDetail(privateKey, orderId);
|
||||||
Generated
+1352
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "polyhermes-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Utility scripts for Polyhermes",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"get-order-detail": "node get-order-detail.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ethersproject/wallet": "^5.7.0",
|
||||||
|
"@polymarket/clob-client": "^5.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user