Compare commits
206 Commits
v1.1.10
...
ai_fix/n_67
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d0e7c82ca | |||
| 04b7505094 | |||
| 2056417749 | |||
| 748c871af4 | |||
| 53f1381c3b | |||
| 4a3ebb674e | |||
| 2b570a432a | |||
| 5412a0eb49 | |||
| 7b4e702da9 | |||
| e32697e7ee | |||
| d8a75fc8dd | |||
| 6cb48bb6cc | |||
| e78785de7c | |||
| 915d4570df | |||
| 48d6e82f43 | |||
| 46c32df421 | |||
| 686d14b6e5 | |||
| 561ebf0ce3 | |||
| b8e10f340c | |||
| 9c70dc762f | |||
| 8f0a49493c | |||
| e7af4d4821 | |||
| 83bc209489 | |||
| 9b6cc63158 | |||
| 96d224a5a3 | |||
| 97249db546 | |||
| 46e10ebdd8 | |||
| 4ebfacfc21 | |||
| f86749e85d | |||
| 3cc37dac55 | |||
| 5483a1aa77 | |||
| e5acad5091 | |||
| 1688fa9633 | |||
| 1967d97c31 | |||
| 79b154515d | |||
| 9b4d8fc001 | |||
| cb1f43871a | |||
| a2be5b7f52 | |||
| 708d6ddb41 | |||
| 0fb015f6d3 | |||
| c5d59dfdf9 | |||
| 4b277eaeab | |||
| f35bad78d4 | |||
| 0740abcf16 | |||
| 377da4fff6 | |||
| 84c79d8812 | |||
| a0c2d7995b | |||
| d3196a783f | |||
| 4c989a48c4 | |||
| 7c1f8df590 | |||
| 5bb46ebb97 | |||
| cbcebf6e28 | |||
| 2013a2eb70 | |||
| de89175c8e | |||
| 4e1bb0bbcf | |||
| 24a4487d0b | |||
| 24bb7bed40 | |||
| 6b56f62532 | |||
| c38529546f | |||
| 08e1219f77 | |||
| 62842f5aa8 | |||
| 13662dd0d5 | |||
| 07c3548401 | |||
| 0f50939625 | |||
| a64d1edbf4 | |||
| a62f1bd6e8 | |||
| 63ce4107c6 | |||
| a136d68e8c | |||
| 61a5077d4d | |||
| bd529d9041 | |||
| 5cdcc487d4 | |||
| ca2b1acbb9 | |||
| 77b681a40e | |||
| c6f9e5db61 | |||
| 2ec4ae3f98 | |||
| 5074bb4f28 | |||
| 9fef4bea59 | |||
| b50e43c239 | |||
| 7ec9311df2 | |||
| 1f6cf1ecaf | |||
| 9413507997 | |||
| bf0e52fc01 | |||
| c520eb7777 | |||
| d6d4b1200e | |||
| a1f3cfd3ff | |||
| 261dbdc23c | |||
| 4c0d0afceb | |||
| b79caceae6 | |||
| ed749fb606 | |||
| f200ba7f7c | |||
| 0efd4a5fc3 | |||
| 2238370088 | |||
| 364db124d1 | |||
| f1ec0a330b | |||
| 72de65d670 | |||
| 3405a1cda3 | |||
| fc6fa8b419 | |||
| cf2c8a611c | |||
| bd323fca35 | |||
| a067a20a02 | |||
| 99c4328830 | |||
| 1df438d18d | |||
| 366d3d1b25 | |||
| 17a6a4f314 | |||
| 8600449f69 | |||
| 3d47bd50d9 | |||
| 20df471683 | |||
| 5487c7c862 | |||
| c1829fe21b | |||
| b1ad6f02d2 | |||
| 01a1836659 | |||
| b616e10ceb | |||
| d07c4c2c5e | |||
| 7485e2ce24 | |||
| 8f52b5016a | |||
| 5c0808c2cb | |||
| 9d01c120e5 | |||
| ec8cfeac77 | |||
| 7c833f1e9b | |||
| 08cc30a90f | |||
| 8288cd7579 | |||
| 55da551971 | |||
| ff3b24b50e | |||
| 88cae4018a | |||
| 96fbc3f720 | |||
| fd25821e39 | |||
| cdd02e9f3d | |||
| fabbd81f22 | |||
| ace32b37cf | |||
| f1eae8a40b | |||
| ca655f351d | |||
| 40081c2464 | |||
| f1f809f54b | |||
| e8fd1b503b | |||
| 390b3ee876 | |||
| 80976609c7 | |||
| 17eea0183f | |||
| 6980781f89 | |||
| 3350039f05 | |||
| 0bdc0c74d1 | |||
| cccc829cef | |||
| fa1a915e9c | |||
| e5992b5145 | |||
| 42472f6b03 | |||
| c56e08e681 | |||
| 7e87965418 | |||
| e115d459f7 | |||
| dd39e59304 | |||
| 2efc04a3c3 | |||
| 2b20f4b2e2 | |||
| 419c68c024 | |||
| 8889803dd7 | |||
| 9c5517768f | |||
| 4aa85a9c2d | |||
| a77b3b10ee | |||
| 532f4c3e25 | |||
| 04629e73b6 | |||
| 696193c571 | |||
| 38e256c4fb | |||
| 64a78406ed | |||
| 59297ec52f | |||
| 0ff6832dc1 | |||
| 56a8928631 | |||
| 266c6d595d | |||
| b2e0816968 | |||
| dc47583565 | |||
| 26dd3bb387 | |||
| fabfe601c6 | |||
| 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 | |||
| 43de0104e2 | |||
| 2ccab42894 | |||
| 3008cbcb50 | |||
| 42a318b501 | |||
| ecb737ec67 | |||
| 0f3baec6cb | |||
| 764d684846 |
@@ -0,0 +1,58 @@
|
||||
# Cursor Skills 目录结构
|
||||
|
||||
本目录遵循 [Cursor Agent Skills 规范](https://cursor.com/cn/docs/context/skills):每个技能为一个**文件夹**,内含 `SKILL.md` 及可选的 `scripts/`、`references/`、`assets/`。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
.cursor/skills/
|
||||
├── frontend/ # 前端相关 skill 分组
|
||||
│ └── check-i18n-keys/ # 单个 skill(文件夹名 = name)
|
||||
│ ├── SKILL.md # 必填,技能定义与指令
|
||||
│ └── scripts/
|
||||
│ ├── check-i18n-keys.ts
|
||||
│ └── package.json
|
||||
├── backend/ # 后端相关 skill
|
||||
└── common/ # 通用 skill
|
||||
└── create-release/ # 创建 GitHub Release
|
||||
├── SKILL.md
|
||||
└── scripts/
|
||||
└── create-release.sh
|
||||
```
|
||||
|
||||
- **SKILL.md**:YAML frontmatter(`name`、`description` 必填,`name` 须与父文件夹名一致、小写连字符)+ 给 Agent 的详细指令。
|
||||
- **scripts/**:Agent 可执行的脚本,在 SKILL.md 中用相对路径引用。
|
||||
|
||||
## 添加新 Skill
|
||||
|
||||
1. **确定分组**:在 `frontend/`、`backend/` 或 `common/` 下新建**以技能名命名的文件夹**(仅小写、数字、连字符,如 `check-i18n-keys`)。
|
||||
2. **创建 SKILL.md**:在该文件夹内创建 `SKILL.md`(大写),frontmatter 中 `name` 必须与文件夹名一致。
|
||||
3. **可选 scripts/**:在技能文件夹内建 `scripts/`,放入可执行脚本;在 SKILL.md 正文中写明运行命令(如 `cd .cursor/skills/.../scripts && npm run xxx`)。
|
||||
|
||||
## 示例
|
||||
|
||||
- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key
|
||||
- `common/create-release/SKILL.md` + `common/create-release/scripts/` — 创建 GitHub Release
|
||||
|
||||
## 运行 check-i18n-keys
|
||||
|
||||
```bash
|
||||
cd .cursor/skills/frontend/check-i18n-keys/scripts
|
||||
npm install
|
||||
npm run check-i18n
|
||||
```
|
||||
|
||||
## 运行 create-release
|
||||
|
||||
```bash
|
||||
cd .cursor/skills/common/create-release/scripts
|
||||
./create-release.sh -t v1.0.0 -T "Release v1.0.0" -d "发布说明"
|
||||
```
|
||||
|
||||
参数说明:
|
||||
- `-t` 版本号(必需,格式 v1.0.0)
|
||||
- `-T` Release 标题
|
||||
- `-d` Release 描述
|
||||
- `-p` 标记为 Pre-release(自动加 -beta 后缀)
|
||||
- `-y` 无交互模式
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
---
|
||||
name: create-release
|
||||
description: 创建 PolyHermes 项目的 GitHub Release。当用户要求发布版本、创建 release、打 tag 或发布新版本时使用。
|
||||
---
|
||||
|
||||
# Create Release
|
||||
|
||||
创建 PolyHermes 项目的 GitHub Release,包括创建 Git tag、推送 tag、创建 GitHub Release(支持 pre-release),并自动在 Issue #1 发布公告。
|
||||
|
||||
## 使用时机
|
||||
|
||||
- 用户要求「发布版本」「创建 release」「打 tag」「发布新版本」时
|
||||
- 用户要求「创建 pre-release」「beta 版本」时
|
||||
- 用户提到「v1.x.x」等版本号相关操作时
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **GitHub CLI 已安装**:确保 `gh` 命令可用
|
||||
2. **已登录 GitHub**:运行 `gh auth status` 确认
|
||||
3. **工作目录干净**:建议先提交所有更改
|
||||
|
||||
## 指令
|
||||
|
||||
### 步骤 1:收集发布信息
|
||||
|
||||
询问用户以下信息:
|
||||
- 版本号(格式:vX.Y.Z,如 v1.0.0)
|
||||
- 是否为 Pre-release(测试版本)
|
||||
- Release 标题和描述(可选,如未提供则自动生成)
|
||||
|
||||
### 步骤 2:生成 Release 内容
|
||||
|
||||
**重要**:如果用户未提供描述,需要根据 Git commits 自动生成。
|
||||
|
||||
1. **获取上一个版本的 tag**:
|
||||
```bash
|
||||
git describe --tags --abbrev=0 HEAD
|
||||
```
|
||||
|
||||
2. **获取版本间的 commits**:
|
||||
```bash
|
||||
git log <PREVIOUS_TAG>..HEAD --oneline --no-merges
|
||||
```
|
||||
|
||||
3. **过滤 commit 规则**:
|
||||
- **排除**:版本内新增功能的修复 commit
|
||||
- **判断方法**:如果一个 commit 的消息包含「fix」「修复」「bugfix」等关键词,且是针对同一版本内新增代码的修复,则不包含
|
||||
- **保留**:新功能、性能优化、重构、文档更新等
|
||||
|
||||
4. **生成中英文 Release 内容**:
|
||||
- 格式要求:**中文在上,英文在下**
|
||||
- 使用分隔线 `---` 分隔中英文部分
|
||||
- 按功能类型分组(新功能、改进、修复等)
|
||||
|
||||
示例格式:
|
||||
```markdown
|
||||
## 新功能
|
||||
|
||||
- 添加了 A 功能
|
||||
- 支持了 B 操作
|
||||
|
||||
## 改进
|
||||
|
||||
- 优化了 C 性能
|
||||
|
||||
---
|
||||
|
||||
## New Features
|
||||
|
||||
- Added feature A
|
||||
- Supported operation B
|
||||
|
||||
## Improvements
|
||||
|
||||
- Optimized performance C
|
||||
```
|
||||
|
||||
### 步骤 3:运行发布脚本
|
||||
|
||||
在项目根目录下执行:
|
||||
|
||||
```bash
|
||||
cd .cursor/skills/common/create-release/scripts && \
|
||||
chmod +x create-release.sh && \
|
||||
./create-release.sh -t <VERSION> [-T "<TITLE>"] [-d "<DESCRIPTION>"] [-p] [-y]
|
||||
```
|
||||
|
||||
### 步骤 4:发布公告到 Issue #1
|
||||
|
||||
**重要**:Release 创建成功后,必须自动在 Issue #1 下发布公告 comment。
|
||||
|
||||
1. **生成公告内容**(面向用户,通俗易懂):
|
||||
|
||||
公告格式模板:
|
||||
```markdown
|
||||
# 🎉 PolyHermes vX.X.X 版本发布公告
|
||||
|
||||
## 📅 发布日期
|
||||
|
||||
YYYY年MM月DD日
|
||||
|
||||
---
|
||||
|
||||
## ✨ 本次更新亮点
|
||||
|
||||
### 🚀 新功能
|
||||
|
||||
**功能名称**
|
||||
- 用通俗的语言描述这个功能是什么
|
||||
- 用户能从中获得什么好处
|
||||
- 如何使用这个功能
|
||||
|
||||
### 🔧 改进优化
|
||||
|
||||
- 优化了 XXX,现在 XXX 更快/更稳定了
|
||||
- 改进了 XXX 体验,操作更简单了
|
||||
|
||||
### 🐛 问题修复
|
||||
|
||||
- 修复了 XXX 问题,不再出现 XXX 情况
|
||||
|
||||
---
|
||||
|
||||
## 📦 如何更新
|
||||
|
||||
### Docker 部署(推荐)
|
||||
|
||||
```bash
|
||||
# 拉取最新镜像
|
||||
docker pull wrbug/polyhermes:vX.X.X
|
||||
|
||||
# 重启服务
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 安全提醒
|
||||
|
||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
||||
|
||||
**官方镜像地址**:`wrbug/polyhermes`
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关链接
|
||||
|
||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/vX.X.X
|
||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||
```
|
||||
|
||||
2. **公告内容编写原则**:
|
||||
|
||||
- ✅ **通俗易懂**:避免技术术语,用用户能理解的语言
|
||||
- ✅ **突出价值**:告诉用户这个更新对他们有什么好处
|
||||
- ✅ **简洁明了**:每个功能点用 1-2 句话说明
|
||||
- ✅ **包含操作指引**:告诉用户如何使用新功能
|
||||
- ✅ **中英文双语**:中文在上,英文在下(可选)
|
||||
- ❌ **避免**:commit hash、代码细节、内部实现
|
||||
|
||||
3. **执行发布公告命令**:
|
||||
|
||||
```bash
|
||||
gh issue comment 1 --repo WrBug/PolyHermes --body "$(cat <<'EOF'
|
||||
# 🎉 PolyHermes vX.X.X 版本发布公告
|
||||
|
||||
[公告内容...]
|
||||
|
||||
---
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
或使用文件方式(内容较长时推荐):
|
||||
|
||||
```bash
|
||||
echo "[公告内容...]" > /tmp/announcement.md
|
||||
gh issue comment 1 --repo WrBug/PolyHermes --body-file /tmp/announcement.md
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `-t, --tag` | 版本号(必需) | `-t v1.0.0` |
|
||||
| `-T, --title` | Release 标题 | `-T "Release v1.0.0"` |
|
||||
| `-d, --description` | Release 描述 | `-d "## 新功能\n- 功能1"` |
|
||||
| `-f, --description-file` | 从文件读取描述 | `-f CHANGELOG.md` |
|
||||
| `-p, --prerelease` | 标记为 Pre-release(自动加 -beta 后缀) | `-p` |
|
||||
| `-y, --yes` | 无交互模式 | `-y` |
|
||||
|
||||
## 版本号格式
|
||||
|
||||
- 必须格式:`v数字.数字.数字`(如 v1.0.0, v1.10.2, v1.1.12)
|
||||
- 如果指定 `--prerelease`,会自动拼接 `-beta` 后缀(如 v1.0.1 → v1.0.1-beta)
|
||||
|
||||
## Release 内容生成规则
|
||||
|
||||
### 中英文格式
|
||||
|
||||
Release 描述**必须**使用中英文双语格式:
|
||||
|
||||
```markdown
|
||||
## 中文标题
|
||||
|
||||
- 内容项1
|
||||
- 内容项2
|
||||
|
||||
---
|
||||
|
||||
## English Title
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
```
|
||||
|
||||
### Commit 过滤规则
|
||||
|
||||
**需要排除的 commit 类型**:
|
||||
|
||||
1. **版本内修复**:对同一版本新增功能的后续修复
|
||||
- 例如:v1.0.1 新增了功能 A,然后有一个 commit 修复功能 A 的 bug → 不包含
|
||||
- 判断依据:commit 消息包含「fix」「修复」「bugfix」且相关功能在本版本新增
|
||||
|
||||
2. **琐碎修改**:
|
||||
- typo 修正
|
||||
- 代码格式调整
|
||||
- 注释更新
|
||||
|
||||
**需要保留的 commit 类型**:
|
||||
|
||||
1. 新功能(feat、feature)
|
||||
2. 改进/优化(improve、optimize、enhance)
|
||||
3. 重要 bug 修复(针对旧版本的 bug)
|
||||
4. 重构(refactor)
|
||||
5. 文档更新(docs)
|
||||
|
||||
### 分组建议
|
||||
|
||||
- **新功能 / New Features**
|
||||
- **改进 / Improvements**
|
||||
- **修复 / Bug Fixes**(仅包含对旧版本 bug 的修复)
|
||||
- **其他 / Others**
|
||||
|
||||
## 公告内容示例
|
||||
|
||||
以下是一个面向用户的公告示例:
|
||||
|
||||
```markdown
|
||||
# 🎉 PolyHermes v1.2.0 版本发布公告
|
||||
|
||||
## 📅 发布日期
|
||||
|
||||
2026年3月2日
|
||||
|
||||
---
|
||||
|
||||
## ✨ 本次更新亮点
|
||||
|
||||
### 🚀 新功能
|
||||
|
||||
**系统自动更新**
|
||||
- 现在可以在网页上直接更新系统,无需手动重启 Docker
|
||||
- 更新过程约 30-60 秒,系统会自动处理
|
||||
- 如果更新失败,系统会自动恢复到旧版本
|
||||
|
||||
**RPC 节点管理**
|
||||
- 可以在系统设置中添加、编辑、删除自定义 RPC 节点
|
||||
- 可以随时启用或禁用节点
|
||||
- 系统会自动选择可用的节点
|
||||
|
||||
### 🔧 改进优化
|
||||
|
||||
- **更快的跟单响应**:通过实时监听链上交易,跟单速度提升到秒级
|
||||
- **更准确的盈亏统计**:系统会自动追踪实际成交价,统计数据更准确
|
||||
- **内存占用优化**:修复了内存泄漏问题,系统可以长时间稳定运行
|
||||
|
||||
### 🐛 问题修复
|
||||
|
||||
- 修复了部分市场无法正确查询价格的问题
|
||||
- 修复了卖出订单偶发失败的问题
|
||||
|
||||
---
|
||||
|
||||
## 📦 如何更新
|
||||
|
||||
### 方式一:网页更新(推荐)
|
||||
|
||||
1. 登录系统,进入 **系统设置** → **系统更新**
|
||||
2. 点击 **检查更新**
|
||||
3. 如果有新版本,点击 **立即升级**
|
||||
4. 等待更新完成即可
|
||||
|
||||
### 方式二:Docker 更新
|
||||
|
||||
```bash
|
||||
docker pull wrbug/polyhermes:v1.2.0
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 安全提醒
|
||||
|
||||
**请务必使用官方 Docker 镜像源!**
|
||||
|
||||
官方镜像:`wrbug/polyhermes`
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关链接
|
||||
|
||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.2.0
|
||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||
```
|
||||
|
||||
## 发布流程
|
||||
|
||||
1. 验证版本号格式
|
||||
2. 检查 Git 工作目录状态
|
||||
3. 检查 tag 是否已存在
|
||||
4. 生成 Release 内容(中英文)
|
||||
5. 创建本地 tag
|
||||
6. 推送 tag 到远程
|
||||
7. 创建 GitHub Release
|
||||
8. **生成面向用户的公告内容**
|
||||
9. **发布公告到 Issue #1**
|
||||
10. 返回 Release URL 和公告链接
|
||||
|
||||
## 注意事项
|
||||
|
||||
- Pre-release 版本不会触发 Telegram 通知
|
||||
- GitHub Actions 会自动触发构建流程
|
||||
- 如果 tag 已存在,会提示是否删除并重新创建
|
||||
- **必须**在 Release 创建成功后发布公告到 Issue #1
|
||||
|
||||
## 可选目录说明
|
||||
|
||||
- `scripts/`:包含 `create-release.sh` 发布脚本
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PolyHermes Release 创建脚本
|
||||
# 功能:创建 tag、推送 tag、创建 GitHub Release(支持 pre-release)
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 打印信息
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 显示使用说明
|
||||
usage() {
|
||||
cat << EOF
|
||||
用法: $0 [选项]
|
||||
|
||||
选项:
|
||||
-t, --tag TAG 版本号 tag(必需,格式:v1.0.0)
|
||||
-T, --title TITLE Release 标题(可选,默认使用 tag)
|
||||
-d, --description DESC Release 描述内容(可选)
|
||||
-f, --description-file FILE 从文件读取 Release 描述(可选)
|
||||
-p, --prerelease 标记为 Pre-release(会自动拼接 -beta 后缀,默认:false)
|
||||
-y, --yes 无交互模式,自动确认所有操作(默认:false)
|
||||
-h, --help 显示此帮助信息
|
||||
|
||||
示例:
|
||||
# 创建正式版本
|
||||
$0 -t v1.0.1 -T "Release v1.0.1" -d "## 新功能\n- 功能1\n- 功能2"
|
||||
|
||||
# 创建 Pre-release(自动拼接 -beta)
|
||||
$0 -t v1.0.1 -T "Release v1.0.1-beta" -d "测试版本" --prerelease
|
||||
# 实际创建的 tag: v1.0.1-beta
|
||||
|
||||
# 从文件读取描述
|
||||
$0 -t v1.0.1 -f CHANGELOG.md --prerelease
|
||||
# 实际创建的 tag: v1.0.1-beta
|
||||
|
||||
# 无交互模式(适合 CI/CD 或自动化脚本)
|
||||
$0 -t v1.0.1 -d "更新内容" --yes
|
||||
|
||||
版本号格式:
|
||||
- 必须格式: v数字.数字.数字 (例如: v1.0.0, v1.10.2, v1.1.12)
|
||||
- 如果指定 --prerelease,会自动拼接 -beta 后缀 (例如: v1.0.1 -> v1.0.1-beta)
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# 验证版本号格式(只允许 v数字.数字.数字,不允许后缀)
|
||||
validate_tag() {
|
||||
local tag=$1
|
||||
# 匹配格式:v数字.数字.数字(不允许后缀)
|
||||
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
error "版本号格式不正确:$tag"
|
||||
error "应为 v数字.数字.数字 (例如: v1.0.0, v1.10.2, v1.1.12)"
|
||||
error "如果创建 Pre-release,请使用 --prerelease 参数,脚本会自动拼接 -beta 后缀"
|
||||
exit 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 检查必要的工具
|
||||
check_requirements() {
|
||||
local auto_yes=$1
|
||||
|
||||
# 检查 git
|
||||
if ! command -v git &> /dev/null; then
|
||||
error "未找到 git 命令,请先安装 git"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 GitHub CLI
|
||||
if ! command -v gh &> /dev/null; then
|
||||
error "未找到 GitHub CLI (gh) 命令"
|
||||
error "请先安装 GitHub CLI: https://cli.github.com/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否已登录 GitHub
|
||||
if ! gh auth status &> /dev/null; then
|
||||
error "未登录 GitHub,请先运行: gh auth login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否有未提交的更改
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
warn "检测到未提交的更改,建议先提交或暂存"
|
||||
if [[ "$auto_yes" == "true" ]]; then
|
||||
info "无交互模式:自动继续"
|
||||
else
|
||||
read -p "是否继续?(y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
info "已取消"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查是否在正确的分支
|
||||
local current_branch=$(git branch --show-current)
|
||||
info "当前分支: $current_branch"
|
||||
}
|
||||
|
||||
# 检查 tag 是否已存在
|
||||
check_tag_exists() {
|
||||
local tag=$1
|
||||
local auto_yes=$2
|
||||
|
||||
if git rev-parse "$tag" >/dev/null 2>&1; then
|
||||
error "Tag $tag 已存在(本地)"
|
||||
if [[ "$auto_yes" == "true" ]]; then
|
||||
info "无交互模式:自动删除并重新创建"
|
||||
git tag -d "$tag" || true
|
||||
git push origin ":refs/tags/$tag" || true
|
||||
info "已删除旧 tag: $tag"
|
||||
else
|
||||
read -p "是否删除并重新创建?(y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
git tag -d "$tag" || true
|
||||
git push origin ":refs/tags/$tag" || true
|
||||
info "已删除旧 tag: $tag"
|
||||
else
|
||||
error "已取消"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查远程是否存在
|
||||
if git ls-remote --tags origin "$tag" | grep -q "$tag"; then
|
||||
error "Tag $tag 已存在于远程仓库"
|
||||
if [[ "$auto_yes" == "true" ]]; then
|
||||
info "无交互模式:自动删除并重新创建"
|
||||
git tag -d "$tag" || true
|
||||
git push origin ":refs/tags/$tag" || true
|
||||
info "已删除远程 tag: $tag"
|
||||
else
|
||||
read -p "是否删除并重新创建?(y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
git tag -d "$tag" || true
|
||||
git push origin ":refs/tags/$tag" || true
|
||||
info "已删除远程 tag: $tag"
|
||||
else
|
||||
error "已取消"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
local TAG=""
|
||||
local TITLE=""
|
||||
local DESCRIPTION=""
|
||||
local DESCRIPTION_FILE=""
|
||||
local PRERELEASE=false
|
||||
local AUTO_YES=false
|
||||
|
||||
# 解析参数
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-t|--tag)
|
||||
TAG="$2"
|
||||
shift 2
|
||||
;;
|
||||
-T|--title)
|
||||
TITLE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--description)
|
||||
DESCRIPTION="$2"
|
||||
shift 2
|
||||
;;
|
||||
-f|--description-file)
|
||||
DESCRIPTION_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-p|--prerelease)
|
||||
PRERELEASE=true
|
||||
shift
|
||||
;;
|
||||
-y|--yes)
|
||||
AUTO_YES=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
error "未知参数: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 检查必需参数
|
||||
if [[ -z "$TAG" ]]; then
|
||||
error "缺少必需参数: --tag"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 验证版本号格式(不允许后缀)
|
||||
validate_tag "$TAG"
|
||||
|
||||
# 如果指定了 --prerelease,自动拼接 -beta 后缀
|
||||
local BASE_TAG="$TAG"
|
||||
if [[ "$PRERELEASE" == "true" ]]; then
|
||||
TAG="${BASE_TAG}-beta"
|
||||
info "Pre-release 模式:tag 将自动拼接 -beta 后缀"
|
||||
info "基础版本: $BASE_TAG -> 实际 tag: $TAG"
|
||||
fi
|
||||
|
||||
# 检查工具和环境
|
||||
check_requirements "$AUTO_YES"
|
||||
|
||||
# 检查 tag 是否已存在(使用拼接后的 tag)
|
||||
check_tag_exists "$TAG" "$AUTO_YES"
|
||||
|
||||
# 设置默认标题
|
||||
if [[ -z "$TITLE" ]]; then
|
||||
TITLE="$TAG"
|
||||
fi
|
||||
|
||||
# 读取描述内容
|
||||
if [[ -n "$DESCRIPTION_FILE" ]]; then
|
||||
if [[ ! -f "$DESCRIPTION_FILE" ]]; then
|
||||
error "描述文件不存在: $DESCRIPTION_FILE"
|
||||
exit 1
|
||||
fi
|
||||
DESCRIPTION=$(cat "$DESCRIPTION_FILE")
|
||||
fi
|
||||
|
||||
# 如果没有描述,使用默认值
|
||||
if [[ -z "$DESCRIPTION" ]]; then
|
||||
if [[ "$PRERELEASE" == "true" ]]; then
|
||||
DESCRIPTION="Pre-release $TAG"
|
||||
else
|
||||
DESCRIPTION="Release $TAG"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 显示即将执行的操作
|
||||
echo
|
||||
info "========================================="
|
||||
info " PolyHermes Release 创建"
|
||||
info "========================================="
|
||||
info "Tag: $TAG"
|
||||
info "Title: $TITLE"
|
||||
info "Pre-release: $PRERELEASE"
|
||||
if [[ "$AUTO_YES" == "true" ]]; then
|
||||
info "模式: 无交互模式(自动确认)"
|
||||
fi
|
||||
info "Description:"
|
||||
echo "$DESCRIPTION" | sed 's/^/ /'
|
||||
info "========================================="
|
||||
echo
|
||||
|
||||
# 确认操作
|
||||
if [[ "$AUTO_YES" == "true" ]]; then
|
||||
info "无交互模式:自动确认创建 Release"
|
||||
else
|
||||
read -p "确认创建 Release?(y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
info "已取消"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. 创建 tag(基于当前 HEAD)
|
||||
info "创建 tag: $TAG"
|
||||
git tag "$TAG"
|
||||
success "Tag 创建成功: $TAG"
|
||||
|
||||
# 2. 推送 tag 到远程
|
||||
info "推送 tag 到远程..."
|
||||
git push origin "$TAG"
|
||||
success "Tag 推送成功"
|
||||
|
||||
# 3. 创建 GitHub Release
|
||||
info "创建 GitHub Release..."
|
||||
|
||||
local RELEASE_ARGS=(
|
||||
"$TAG"
|
||||
--title "$TITLE"
|
||||
--notes "$DESCRIPTION"
|
||||
)
|
||||
|
||||
if [[ "$PRERELEASE" == "true" ]]; then
|
||||
RELEASE_ARGS+=(--prerelease)
|
||||
fi
|
||||
|
||||
if gh release create "${RELEASE_ARGS[@]}"; then
|
||||
success "GitHub Release 创建成功!"
|
||||
|
||||
# 获取 release URL
|
||||
local RELEASE_URL=$(gh release view "$TAG" --json url -q .url)
|
||||
info "Release URL: $RELEASE_URL"
|
||||
|
||||
echo
|
||||
success "========================================="
|
||||
success " Release 创建完成!"
|
||||
success "========================================="
|
||||
success "Tag: $TAG"
|
||||
success "Pre-release: $PRERELEASE"
|
||||
success "URL: $RELEASE_URL"
|
||||
success "========================================="
|
||||
echo
|
||||
info "GitHub Actions 将自动触发构建流程"
|
||||
|
||||
if [[ "$PRERELEASE" == "true" ]]; then
|
||||
warn "这是 Pre-release,GitHub Actions 不会发送 Telegram 通知"
|
||||
fi
|
||||
else
|
||||
error "GitHub Release 创建失败"
|
||||
error "请手动在 GitHub 上创建 Release: https://github.com/WrBug/PolyHermes/releases/new"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: check-i18n-keys
|
||||
description: 检查前端多语言 key 完整性。当用户要求检查 i18n、多语言缺失、翻译 key 或运行 check-i18n 时使用。
|
||||
---
|
||||
|
||||
# Check I18n Keys
|
||||
|
||||
检查前端代码中使用的 i18n key 是否在所有语言文件(zh-CN、zh-TW、en)中存在,并报告缺失或语言间不一致的 key。
|
||||
|
||||
## 使用时机
|
||||
|
||||
- 用户要求「检查多语言」「检查 i18n」「扫一下 key」「多语言缺失」时
|
||||
- 用户要求运行多语言检查或执行 check-i18n 时
|
||||
- 在修改或新增前端文案后,需要确认三语言 key 一致时
|
||||
|
||||
## 指令
|
||||
|
||||
1. **运行检查脚本**(在项目仓库根目录下执行):
|
||||
```bash
|
||||
cd .cursor/skills/frontend/check-i18n-keys/scripts && npm install && npm run check-i18n
|
||||
```
|
||||
首次运行需先 `npm install`,之后可直接 `npm run check-i18n`。
|
||||
|
||||
2. **脚本行为**:
|
||||
- 扫描 `frontend/` 下所有 `.ts`、`.tsx`、`.js`、`.jsx`(排除 node_modules、dist、build、*.d.ts)
|
||||
- 提取代码中 `t('key')` / `t("key")` 的 key
|
||||
- 与 `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 对比
|
||||
- 报告:代码中使用但某语言 JSON 缺失的 key;某语言有而另一语言没有的 key(不一致)
|
||||
|
||||
3. **结果处理**:
|
||||
- 若有缺失或不一致,退出码为 1;可根据报告在对应 `common.json` 中补全 key
|
||||
- 脚本路径:`scripts/check-i18n-keys.ts`(相对本 skill 根目录)
|
||||
|
||||
## 可选目录说明
|
||||
|
||||
- `scripts/`:可执行检查脚本,Agent 按上述命令调用。
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 检查前端多语言 key 完整性
|
||||
*
|
||||
* 扫描范围:frontend 下所有 .ts / .tsx / .js / .jsx(排除 node_modules、dist、build、*.d.ts)
|
||||
*
|
||||
* 支持的检查:
|
||||
* 1. 代码引用但 JSON 没有:某 ts/js 引用了 t("common.test"),但任意语言 common.json 里没有该 key → 报缺失
|
||||
* 2. 语言间不一致:zh-CN/zh-TW 的 JSON 有 common.xxx,但 en 的没有 → 报不一致(某些语言有、某些没有)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { glob } from 'glob';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
interface CheckResult {
|
||||
usedKeys: Set<string>;
|
||||
localeKeys: {
|
||||
'zh-CN': Set<string>;
|
||||
'zh-TW': Set<string>;
|
||||
'en': Set<string>;
|
||||
};
|
||||
missingKeys: {
|
||||
'zh-CN': string[];
|
||||
'zh-TW': string[];
|
||||
'en': string[];
|
||||
};
|
||||
inconsistentKeys: Array<{
|
||||
key: string;
|
||||
existsIn: string[];
|
||||
missingIn: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
const LOCALES = ['zh-CN', 'zh-TW', 'en'] as const;
|
||||
type Locale = typeof LOCALES[number];
|
||||
|
||||
// 颜色输出(如果支持)
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
function log(message: string, color: keyof typeof colors = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从代码文件中提取所有 t() 调用中的 key
|
||||
*/
|
||||
function extractKeysFromCode(frontendDir: string): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
|
||||
// 匹配 t('key') 或 t("key") 或 t(`key`)
|
||||
const tPattern = /t\(['"`]([^'"`]+)['"`]\)/g;
|
||||
|
||||
// 扫描所有 TS/JS 文件(含 .ts .tsx .js .jsx),排除 node_modules、dist、build、*.d.ts
|
||||
const files = glob.sync('**/*.{ts,tsx,js,jsx}', {
|
||||
cwd: frontendDir,
|
||||
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.d.ts'],
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
let match;
|
||||
|
||||
while ((match = tPattern.exec(content)) !== null) {
|
||||
const key = match[1];
|
||||
// 过滤掉模板字符串中的变量(如 t(`key.${variable}`))
|
||||
if (!key.includes('${') && !key.includes('${')) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${file}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载语言文件
|
||||
*/
|
||||
function loadLocaleFile(localeDir: string, locale: Locale): Record<string, any> {
|
||||
const filePath = path.join(localeDir, locale, 'common.json');
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
log(`⚠️ 语言文件不存在: ${filePath}`, 'yellow');
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
log(`❌ 解析语言文件失败 ${filePath}: ${error}`, 'red');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展平嵌套对象为点分隔的 key 路径
|
||||
*/
|
||||
function flattenKeys(obj: Record<string, any>, prefix = ''): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
// 递归处理嵌套对象
|
||||
const nestedKeys = flattenKeys(value, fullKey);
|
||||
nestedKeys.forEach(k => keys.add(k));
|
||||
} else {
|
||||
// 叶子节点
|
||||
keys.add(fullKey);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 key 完整性
|
||||
*/
|
||||
function checkKeys(usedKeys: Set<string>, localeKeys: Record<Locale, Set<string>>): CheckResult {
|
||||
const result: CheckResult = {
|
||||
usedKeys,
|
||||
localeKeys,
|
||||
missingKeys: {
|
||||
'zh-CN': [],
|
||||
'zh-TW': [],
|
||||
'en': [],
|
||||
},
|
||||
inconsistentKeys: [],
|
||||
};
|
||||
|
||||
// 检查每个语言文件中缺失的 key
|
||||
for (const locale of LOCALES) {
|
||||
for (const key of usedKeys) {
|
||||
if (!localeKeys[locale].has(key)) {
|
||||
result.missingKeys[locale].push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查不一致的 key(某些语言有,某些没有)
|
||||
const allLocaleKeys = new Set<string>();
|
||||
LOCALES.forEach(locale => {
|
||||
localeKeys[locale].forEach(key => allLocaleKeys.add(key));
|
||||
});
|
||||
|
||||
for (const key of allLocaleKeys) {
|
||||
const existsIn: Locale[] = [];
|
||||
const missingIn: Locale[] = [];
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
if (localeKeys[locale].has(key)) {
|
||||
existsIn.push(locale);
|
||||
} else {
|
||||
missingIn.push(locale);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果某些语言有,某些没有,则不一致
|
||||
if (existsIn.length > 0 && existsIn.length < LOCALES.length) {
|
||||
result.inconsistentKeys.push({
|
||||
key,
|
||||
existsIn,
|
||||
missingIn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印检查报告
|
||||
*/
|
||||
function printReport(result: CheckResult): void {
|
||||
log('\n=== 多语言 Key 检查报告 ===\n', 'cyan');
|
||||
|
||||
// 统计信息
|
||||
log('📊 统计信息:', 'blue');
|
||||
log(` - 代码中使用的 key 数量: ${result.usedKeys.size}`);
|
||||
log(` - zh-CN 语言文件 key 数量: ${result.localeKeys['zh-CN'].size}`);
|
||||
log(` - zh-TW 语言文件 key 数量: ${result.localeKeys['zh-TW'].size}`);
|
||||
log(` - en 语言文件 key 数量: ${result.localeKeys['en'].size}`);
|
||||
log('');
|
||||
|
||||
// 缺失的 key
|
||||
let hasMissing = false;
|
||||
for (const locale of LOCALES) {
|
||||
if (result.missingKeys[locale].length > 0) {
|
||||
hasMissing = true;
|
||||
log(`❌ 缺失的 Key (代码中使用但 ${locale} 语言文件中不存在):`, 'red');
|
||||
result.missingKeys[locale].forEach(key => {
|
||||
log(` - ${key}`, 'red');
|
||||
});
|
||||
log('');
|
||||
}
|
||||
}
|
||||
|
||||
// 不一致的 key
|
||||
if (result.inconsistentKeys.length > 0) {
|
||||
log('⚠️ 不一致的 Key (某些语言文件有,某些没有):', 'yellow');
|
||||
result.inconsistentKeys.forEach(({ key, existsIn, missingIn }) => {
|
||||
log(` - ${key}`, 'yellow');
|
||||
log(` 存在于: ${existsIn.join(', ')}`, 'yellow');
|
||||
log(` 缺失于: ${missingIn.join(', ')}`, 'yellow');
|
||||
});
|
||||
log('');
|
||||
}
|
||||
|
||||
// 总结
|
||||
const hasErrors = hasMissing || result.inconsistentKeys.length > 0;
|
||||
|
||||
if (hasErrors) {
|
||||
log('❌ 检查失败:发现缺失或不一致的 key', 'red');
|
||||
} else {
|
||||
log('✅ 检查通过:所有 key 都完整且一致', 'green');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主函数
|
||||
*/
|
||||
function main(): void {
|
||||
// 脚本位于 .cursor/skills/frontend/check-i18n-keys/scripts/,向上到项目根
|
||||
const scriptDir = __dirname;
|
||||
const skillDir = path.resolve(scriptDir, '..');
|
||||
const frontendSkillDir = path.resolve(skillDir, '..');
|
||||
const repoRoot = path.resolve(frontendSkillDir, '../../..');
|
||||
const frontendDir = path.join(repoRoot, 'frontend');
|
||||
const localesDir = path.join(frontendDir, 'src', 'locales');
|
||||
|
||||
// 检查目录是否存在
|
||||
if (!fs.existsSync(frontendDir)) {
|
||||
log(`❌ 前端目录不存在: ${frontendDir}`, 'red');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(localesDir)) {
|
||||
log(`❌ 语言文件目录不存在: ${localesDir}`, 'red');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log('🔍 开始检查多语言 key...\n', 'cyan');
|
||||
|
||||
// 1. 提取代码中使用的 key
|
||||
log('📝 扫描代码文件...', 'blue');
|
||||
const usedKeys = extractKeysFromCode(frontendDir);
|
||||
log(` 找到 ${usedKeys.size} 个使用的 key\n`, 'green');
|
||||
|
||||
// 2. 加载语言文件
|
||||
log('📚 加载语言文件...', 'blue');
|
||||
const localeKeys: Record<Locale, Set<string>> = {
|
||||
'zh-CN': new Set(),
|
||||
'zh-TW': new Set(),
|
||||
'en': new Set(),
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const localeObj = loadLocaleFile(localesDir, locale);
|
||||
localeKeys[locale] = flattenKeys(localeObj);
|
||||
log(` ${locale}: ${localeKeys[locale].size} 个 key`, 'green');
|
||||
}
|
||||
log('');
|
||||
|
||||
// 3. 检查
|
||||
log('🔎 检查 key 完整性...', 'blue');
|
||||
const result = checkKeys(usedKeys, localeKeys);
|
||||
|
||||
// 4. 输出报告
|
||||
printReport(result);
|
||||
|
||||
// 5. 退出码
|
||||
const hasErrors =
|
||||
result.missingKeys['zh-CN'].length > 0 ||
|
||||
result.missingKeys['zh-TW'].length > 0 ||
|
||||
result.missingKeys['en'].length > 0 ||
|
||||
result.inconsistentKeys.length > 0;
|
||||
|
||||
process.exit(hasErrors ? 1 : 0);
|
||||
}
|
||||
|
||||
// 运行
|
||||
main();
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
{
|
||||
"name": "cursor-skill-frontend-scripts",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cursor-skill-frontend-scripts",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"glob": "^13.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
|
||||
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.3",
|
||||
"@esbuild/android-arm": "0.27.3",
|
||||
"@esbuild/android-arm64": "0.27.3",
|
||||
"@esbuild/android-x64": "0.27.3",
|
||||
"@esbuild/darwin-arm64": "0.27.3",
|
||||
"@esbuild/darwin-x64": "0.27.3",
|
||||
"@esbuild/freebsd-arm64": "0.27.3",
|
||||
"@esbuild/freebsd-x64": "0.27.3",
|
||||
"@esbuild/linux-arm": "0.27.3",
|
||||
"@esbuild/linux-arm64": "0.27.3",
|
||||
"@esbuild/linux-ia32": "0.27.3",
|
||||
"@esbuild/linux-loong64": "0.27.3",
|
||||
"@esbuild/linux-mips64el": "0.27.3",
|
||||
"@esbuild/linux-ppc64": "0.27.3",
|
||||
"@esbuild/linux-riscv64": "0.27.3",
|
||||
"@esbuild/linux-s390x": "0.27.3",
|
||||
"@esbuild/linux-x64": "0.27.3",
|
||||
"@esbuild/netbsd-arm64": "0.27.3",
|
||||
"@esbuild/netbsd-x64": "0.27.3",
|
||||
"@esbuild/openbsd-arm64": "0.27.3",
|
||||
"@esbuild/openbsd-x64": "0.27.3",
|
||||
"@esbuild/openharmony-arm64": "0.27.3",
|
||||
"@esbuild/sunos-x64": "0.27.3",
|
||||
"@esbuild/win32-arm64": "0.27.3",
|
||||
"@esbuild/win32-ia32": "0.27.3",
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz",
|
||||
"integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.1.2",
|
||||
"minipass": "^7.1.2",
|
||||
"path-scurry": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
|
||||
"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
|
||||
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "cursor-skill-frontend-scripts",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check-i18n": "tsx check-i18n-keys.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"glob": "^13.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"downlevelIteration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
+2
-2
@@ -14,11 +14,11 @@
|
||||
.DS_Store
|
||||
|
||||
# 构建产物
|
||||
backend/build/
|
||||
# 注意:frontend/dist 和 backend/build/libs 在使用 BUILD_IN_DOCKER=false 时是必需的
|
||||
# 所以不能忽略它们。在 BUILD_IN_DOCKER=true 时,它们会被 Docker 内部编译覆盖
|
||||
backend/.gradle/
|
||||
backend/out/
|
||||
backend/bin/
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
frontend/.vite/
|
||||
frontend/.cache/
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
name: 🤖 Bug Report for AI Fix / AI Bug 报告
|
||||
description: Bug 报告模板(提交后请手动添加 'fix via ai' 标签触发自动修复)
|
||||
title: '[Bug] / '
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Bug Report Template 🐛
|
||||
|
||||
本模板用于报告 PolyHermes 项目的 bug。
|
||||
|
||||
⚠️ **Important / 重要提示**:
|
||||
- After submission, if you need AI auto-fix, please manually add the `fix via ai` label
|
||||
/ 提交后,如需 AI 自动修复,请手动添加 `fix via ai` 标签
|
||||
- AI fixes will be on the `fix_issues_by_ai` branch / AI 修复将在 `fix_issues_by_ai` 分支上进行
|
||||
- All AI fixes require human review before merging / 所有 AI 修复需要人工审核后才能合并
|
||||
- Security vulnerabilities, database migrations, major changes are not recommended for AI auto-fix
|
||||
/ 涉及安全漏洞、数据库迁移、重大变更等问题不建议使用 AI 自动修复
|
||||
|
||||
---
|
||||
|
||||
本模板用于报告 PolyHermes 项目的 bug。
|
||||
|
||||
This template is for reporting bugs in the PolyHermes project.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 📝 Bug Description / Bug 描述
|
||||
description: Clearly and concisely describe the bug / 清晰简洁地描述这个 bug
|
||||
placeholder: Describe the bug you encountered / 描述你遇到的问题...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: type
|
||||
attributes:
|
||||
label: 🎯 Bug Type / 问题类型
|
||||
description: Select the type of bug / 选择问题类型
|
||||
options:
|
||||
- Frontend bug (UI/UX/interaction) / 前端 bug (UI/UX/交互问题)
|
||||
- Backend bug (API/logic/data) / 后端 bug (API/逻辑/数据处理)
|
||||
- Database issue / 数据库问题
|
||||
- Performance issue / 性能问题
|
||||
- Configuration/Deployment / 配置/部署问题
|
||||
- Documentation / 文档问题
|
||||
- Other / 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: 📍 Affected Scope / 影响范围
|
||||
description: Select the scope of impact / 选择问题影响范围
|
||||
options:
|
||||
- Specific page/function only / 仅影响特定页面/功能
|
||||
- Entire system / 影响整个系统
|
||||
- Specific user role / 影响特定用户角色
|
||||
- Only in specific environment / 仅在特定环境下重现
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 🔍 Steps to Reproduce / 复现步骤
|
||||
description: Provide clear, detailed steps to reproduce the bug / 提供清晰、详细的步骤来重现这个 bug
|
||||
placeholder: |
|
||||
1. Visit page: `...` / 访问页面:`...`
|
||||
2. Click button: `...` / 点击按钮:`...`
|
||||
3. Input data: `...` / 输入数据:`...`
|
||||
4. Submit form: `...` / 提交表单:`...`
|
||||
5. Observe error: `...` / 观察到错误:`...`
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: frequency
|
||||
attributes:
|
||||
label: Reproduction Frequency / 复现频率
|
||||
options:
|
||||
- Always reproducible (100%) / 总是能复现 (100%)
|
||||
- Frequently reproducible (50%+) / 经常能复现 (50%+)
|
||||
- Occasionally reproducible (<50%) / 偶尔能复现 (<50%)
|
||||
- Hard to reproduce / 很难复现
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 💻 Expected Behavior / 预期行为
|
||||
description: Describe what you expected to happen / 描述你期望发生什么
|
||||
placeholder: What should happen / 应该发生什么...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: ❌ Actual Behavior / 实际行为
|
||||
description: Describe what actually happened / 描述实际发生了什么
|
||||
placeholder: What actually happened / 实际发生了什么...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 📸 Screenshots / Recordings / 截图/录屏
|
||||
description: If applicable, add screenshots or recordings to illustrate the problem (drag and drop files here)
|
||||
/ 如果适用,添加截图或录屏来说明问题(可以拖拽文件到这里)
|
||||
placeholder: Add screenshots or recordings / 添加截图或录屏...
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 🌐 Environment / 环境
|
||||
description: Provide relevant environment information / 提供相关环境信息
|
||||
value: |
|
||||
**Browser (for frontend issues) / 浏览器(前端问题):**
|
||||
- Browser: ______ / 浏览器:______
|
||||
- Browser version: ______ / 浏览器版本:______
|
||||
- Operating System: ______ / 操作系统:______
|
||||
|
||||
**Backend Environment (for backend issues) / 后端环境(后端问题):**
|
||||
- Node.js version: ______ / Node.js 版本:______
|
||||
- Database version: ______ / 数据库版本:______
|
||||
- Docker version (if used): ______ / Docker 版本(如果使用):______
|
||||
- Other relevant dependencies: ______ / 其他相关依赖版本:______
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: related-files
|
||||
attributes:
|
||||
label: 📁 Related Files / Code / 相关文件/代码
|
||||
description: Provide relevant file paths or code snippets / 提供可能涉及的文件路径或相关代码片段
|
||||
placeholder: |
|
||||
Possibly related files / 可能涉及的文件:
|
||||
- frontend/src/components/...
|
||||
- backend/src/main/kotlin/...
|
||||
|
||||
Error logs / 错误日志:
|
||||
```
|
||||
Paste error logs here / 粘贴错误日志
|
||||
```
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: suggestions
|
||||
attributes:
|
||||
label: 🎯 Fix Suggestions (Optional) / 修复建议(可选)
|
||||
description: If you have fix ideas, describe them briefly / 如果你有修复思路,可以在这里简单描述
|
||||
placeholder: |
|
||||
Suggest adding ZZZ check in the YYY method of file XXX
|
||||
/ 建议在 XXX 文件的 YYY 方法中,添加 ZZZ 检查
|
||||
...
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 🚨 Priority / 优先级
|
||||
options:
|
||||
- 🔴 High - Blocking core functionality, affects user experience / 高 - 阻塞核心功能,影响用户体验
|
||||
- 🟡 Medium - Limited functionality but not blocking / 中 - 功能受限但不阻塞
|
||||
- 🟢 Low - Minor issue, doesn't affect usage / 低 - 小问题,不影响使用
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 📝 Additional Information / 补充说明
|
||||
description: Any other information that helps AI understand and fix the issue / 任何其他有助于 AI 理解和修复问题的信息
|
||||
placeholder: |
|
||||
- Was this bug introduced recently? / 这个 bug 是最近引入的吗?
|
||||
- Is it related to a specific PR or commit? / 是否与某个特定的 PR 或 commit 相关?
|
||||
- Does it only occur with specific datasets or users? / 是否只在特定数据集或特定用户情况下出现?
|
||||
/ ...
|
||||
Any other context / 其他任何上下文信息...
|
||||
|
||||
- type: checkboxes
|
||||
id: ai-fix-approval
|
||||
attributes:
|
||||
label: 🤖 AI Auto-Fix Confirmation / AI 自动修复确认
|
||||
description: If you need AI to auto-fix this issue, check the options below (remember to add 'fix via ai' label after submission)
|
||||
/ 如需 AI 自动修复此 issue,请勾选下方选项(提交后记得添加 'fix via ai' 标签)
|
||||
options:
|
||||
- label: |
|
||||
I understand the AI auto-fix workflow and agree to manually review the AI-created PR
|
||||
/ 我已了解 AI 自动修复的工作流程,并同意在 AI 创建 PR 后进行人工审核
|
||||
required: false
|
||||
- label: |
|
||||
This issue is suitable for AI auto-fix (not a security vulnerability, not a database migration, not a major change)
|
||||
/ 此问题适合 AI 自动修复(非安全漏洞、非数据库迁移、非重大变更)
|
||||
required: false
|
||||
@@ -0,0 +1,127 @@
|
||||
# PR 合并后自动关闭关联的 Issue
|
||||
# 当 PR 从 ai_fix/N_xxx 分支合并到 main 时,关闭 #N 对应的 Issue(若 PR 描述中未含 Closes #N 则通过分支名解析)
|
||||
# 关闭后发送 Telegram 通知(复用 TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID)
|
||||
|
||||
name: Close issue on PR merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
close-issue:
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Get PR info
|
||||
id: pr
|
||||
run: |
|
||||
# 从 PR body 查找 Closes #N / Fixes #N(若已有则 GitHub 已自动关 issue,本 step 仅做解析)
|
||||
BODY="${{ github.event.pull_request.body }}"
|
||||
HEAD_REF="${{ github.event.pull_request.head.ref }}"
|
||||
|
||||
# 优先从 PR 描述解析(无匹配时 grep 会 exit 1,需 || true 避免 set -e 导致脚本退出)
|
||||
ISSUE_NUM=$(echo "$BODY" | grep -oE '(Closes|Fixes|Resolves) #([0-9]+)' | head -1 | grep -oE '[0-9]+' || true)
|
||||
if [ -z "$ISSUE_NUM" ]; then
|
||||
# 从分支名解析 ai_fix/N_xxx
|
||||
ISSUE_NUM=$(echo "$HEAD_REF" | sed -n 's|^ai_fix/\([0-9]*\)_.*|\1|p')
|
||||
fi
|
||||
|
||||
if [ -z "$ISSUE_NUM" ]; then
|
||||
echo "ISSUE_NUMBER=" >> $GITHUB_OUTPUT
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "未从 PR 描述或分支名解析到 Issue 编号,跳过关闭"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "ISSUE_NUMBER=$ISSUE_NUM" >> $GITHUB_OUTPUT
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "解析到 Issue #$ISSUE_NUM"
|
||||
|
||||
- name: Get issue details
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
id: issue
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
ISSUE_NUM="${{ steps.pr.outputs.ISSUE_NUMBER }}"
|
||||
# 获取 issue 标题与 URL(用于 TG 消息)
|
||||
JSON=$(gh issue view "$ISSUE_NUM" --json title,url 2>/dev/null || echo '{"title":"","url":""}')
|
||||
TITLE=$(echo "$JSON" | jq -r '.title')
|
||||
ISSUE_URL=$(echo "$JSON" | jq -r '.url')
|
||||
echo "title<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$TITLE" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
echo "url=$ISSUE_URL" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Close issue
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issueNumber = parseInt('${{ steps.pr.outputs.ISSUE_NUMBER }}', 10);
|
||||
if (!issueNumber || isNaN(issueNumber)) {
|
||||
console.log('No valid issue number, skip');
|
||||
return;
|
||||
}
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
if (issue.state === 'open') {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: 'closed'
|
||||
});
|
||||
console.log(`Issue #${issueNumber} closed.`);
|
||||
} else {
|
||||
console.log(`Issue #${issueNumber} already closed.`);
|
||||
}
|
||||
|
||||
- name: Send Telegram notification
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
run: |
|
||||
# 与 docker-build 一致:未配置则跳过
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ISSUE_NUM="${{ steps.pr.outputs.ISSUE_NUMBER }}"
|
||||
ISSUE_TITLE="${{ steps.issue.outputs.title }}"
|
||||
ISSUE_URL="${{ steps.issue.outputs.url }}"
|
||||
PR_URL="${{ github.event.pull_request.html_url }}"
|
||||
|
||||
# 与 docker-build 相同的 HTML 消息格式
|
||||
MESSAGE="✅ <b>AI 修复的 Issue 已关闭</b>"$'\n'$'\n'"🔢 <b>Issue:</b> #${ISSUE_NUM} ${ISSUE_TITLE}"$'\n'"📎 <a href=\"${ISSUE_URL}\">查看 Issue</a>"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
|
||||
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||
--arg text "$MESSAGE" \
|
||||
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
RESPONSE=$(cat /tmp/telegram_response.json)
|
||||
if echo "$RESPONSE" | grep -q '"ok":true'; then
|
||||
echo "✅ Telegram 通知发送成功"
|
||||
else
|
||||
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
echo "❌ 发送 Telegram 消息时发生错误"
|
||||
exit 0
|
||||
fi
|
||||
@@ -4,39 +4,197 @@ on:
|
||||
release:
|
||||
types:
|
||||
- published # 当通过 GitHub Releases 页面创建 release 时触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: '版本号(例如: v1.0.0)'
|
||||
required: false
|
||||
type: string
|
||||
tag_name:
|
||||
description: 'Git Tag 名称(留空则使用 version)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write # 需要写权限以上传 Assets
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }} # 使用 release 对应的 tag
|
||||
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
|
||||
|
||||
- name: Extract version from release
|
||||
- name: Extract version and check if pre-release
|
||||
id: extract_version
|
||||
run: |
|
||||
# 从 release tag 中提取版本号(例如 v1.0.0 -> 1.0.0)
|
||||
TAG_NAME="${{ github.event.release.tag_name }}"
|
||||
if [ -z "$TAG_NAME" ]; then
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
# 从不同事件源提取版本号
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
# Release 事件:从 release tag 中提取
|
||||
TAG_NAME="${{ github.event.release.tag_name }}"
|
||||
IS_PRERELEASE="${{ github.event.release.prerelease }}"
|
||||
else
|
||||
# workflow_dispatch 事件:从输入参数中提取
|
||||
TAG_NAME="${{ github.event.inputs.tag_name }}"
|
||||
if [ -z "$TAG_NAME" ]; then
|
||||
TAG_NAME="${{ github.event.inputs.version }}"
|
||||
fi
|
||||
# 如果仍然为空,尝试从 git ref 中提取
|
||||
if [ -z "$TAG_NAME" ]; then
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
|
||||
# 不是 tag,尝试从分支名或 commit SHA 获取
|
||||
TAG_NAME=${GITHUB_REF#refs/heads/}
|
||||
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
|
||||
TAG_NAME="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "⚠️ 未指定版本号,使用临时版本: $TAG_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
IS_PRERELEASE="false"
|
||||
fi
|
||||
|
||||
# 验证版本号格式:v数字.数字.数字[-后缀](例如 v1.0.0, v2.10.102, v1.0.0-beta)
|
||||
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "错误: 版本号格式不正确,应为 v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
|
||||
exit 1
|
||||
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] && [[ ! "$TAG_NAME" =~ ^dev- ]]; then
|
||||
echo "⚠️ 警告: 版本号格式不符合标准,但仍将继续构建"
|
||||
echo " 当前版本号: $TAG_NAME"
|
||||
echo " 标准格式应为: v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
|
||||
fi
|
||||
|
||||
VERSION=${TAG_NAME#v} # 移除 v 前缀
|
||||
VERSION=${TAG_NAME#v} # 移除 v 前缀(如果存在)
|
||||
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION"
|
||||
echo "Full tag: $TAG_NAME"
|
||||
echo "IS_PRERELEASE=$IS_PRERELEASE" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
echo "📋 这是 Pre-release: $TAG_NAME"
|
||||
else
|
||||
echo "📦 这是正式版本: $TAG_NAME"
|
||||
fi
|
||||
|
||||
- name: Send Telegram notification (build started)
|
||||
# ============ 编译前后端产物 ============
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Build Backend JAR
|
||||
run: |
|
||||
cd backend
|
||||
chmod +x gradlew
|
||||
./gradlew bootJar --no-daemon
|
||||
echo "✅ 后端构建完成"
|
||||
ls -lh build/libs/*.jar
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Build Frontend
|
||||
env:
|
||||
VERSION: ${{ steps.extract_version.outputs.VERSION }}
|
||||
GIT_TAG: ${{ steps.extract_version.outputs.TAG }}
|
||||
GITHUB_REPO_URL: https://github.com/WrBug/PolyHermes
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
echo "✅ 前端构建完成"
|
||||
echo "📦 版本信息: VERSION=${{ steps.extract_version.outputs.VERSION }}, GIT_TAG=${{ steps.extract_version.outputs.TAG }}"
|
||||
du -sh dist/
|
||||
|
||||
# ============ 打包更新包 ============
|
||||
- name: Create Update Package
|
||||
run: |
|
||||
echo "📦 开始打包更新包..."
|
||||
|
||||
# 创建目录结构
|
||||
mkdir -p update-package/backend
|
||||
mkdir -p update-package/frontend
|
||||
|
||||
# 复制后端 JAR
|
||||
cp backend/build/libs/*.jar update-package/backend/polyhermes.jar
|
||||
echo "✅ 后端 JAR 已复制"
|
||||
|
||||
# 复制前端产物
|
||||
cp -r frontend/dist/* update-package/frontend/
|
||||
echo "✅ 前端文件已复制"
|
||||
|
||||
# 创建版本信息文件
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_NOTES=$(echo '${{ github.event.release.body }}' | jq -Rs .)
|
||||
else
|
||||
RELEASE_NOTES="\"手动构建 - workflow_dispatch\""
|
||||
fi
|
||||
|
||||
cat > update-package/version.json <<EOF
|
||||
{
|
||||
"version": "${{ steps.extract_version.outputs.VERSION }}",
|
||||
"tag": "${{ steps.extract_version.outputs.TAG }}",
|
||||
"buildTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"releaseNotes": ${RELEASE_NOTES}
|
||||
}
|
||||
EOF
|
||||
echo "✅ 版本信息已创建"
|
||||
|
||||
# 打包成 tar.gz
|
||||
cd update-package
|
||||
tar -czf ../polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz .
|
||||
cd ..
|
||||
|
||||
echo "✅ 打包完成: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz"
|
||||
ls -lh polyhermes-*.tar.gz
|
||||
|
||||
- name: Calculate Checksum
|
||||
id: checksum
|
||||
run: |
|
||||
FILE="polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz"
|
||||
CHECKSUM=$(sha256sum "$FILE" | awk '{print $1}')
|
||||
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||
echo "✅ SHA256: $CHECKSUM"
|
||||
echo "$CHECKSUM $FILE" > checksums.txt
|
||||
|
||||
- name: Upload Update Package to Release
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ./polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||
asset_name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||
asset_content_type: application/gzip
|
||||
|
||||
- name: Upload Checksums to Release
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ./checksums.txt
|
||||
asset_name: checksums.txt
|
||||
asset_content_type: text/plain
|
||||
|
||||
- name: Upload Update Package as Artifact
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update
|
||||
path: |
|
||||
polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||
checksums.txt
|
||||
retention-days: 30
|
||||
|
||||
# ============ 发送产物上传成功通知 ============
|
||||
- name: Send Telegram notification (package uploaded)
|
||||
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
@@ -48,12 +206,15 @@ jobs:
|
||||
fi
|
||||
|
||||
# 获取构建信息
|
||||
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
MESSAGE="🔨 <b>Docker 镜像构建中</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
MESSAGE="✅ <b>PolyHermes Package Built</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"🔧 <b>Type:</b> Online Update"$'\n'"🔗 <a href=\"${RELEASE_URL}\">View Release</a>"$'\n'"📍 <b>Update Path:</b> System Management → Overview → Check for Updates"$'\n'$'\n'"🐳 Building Docker image..."
|
||||
else
|
||||
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
MESSAGE="✅ <b>PolyHermes Package Built</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"🔧 <b>Type:</b> Online Update"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">View Workflow</a>"$'\n'$'\n'"🐳 Building Docker image..."
|
||||
fi
|
||||
|
||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
@@ -70,15 +231,14 @@ jobs:
|
||||
echo "✅ Telegram 通知发送成功"
|
||||
else
|
||||
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||
# 通知失败不应该导致整个 job 失败
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
echo "❌ 发送 Telegram 消息时发生错误"
|
||||
# 通知失败不应该导致整个 job 失败
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ============ Docker 构建 ============
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
@@ -91,7 +251,24 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Prepare Docker build context
|
||||
run: |
|
||||
echo "📦 准备 Docker 构建上下文..."
|
||||
# 确保构建产物存在且可访问
|
||||
if [ ! -d "frontend/dist" ]; then
|
||||
echo "❌ 错误:frontend/dist 不存在"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "backend/build/libs" ] || [ -z "$(ls -A backend/build/libs/*.jar 2>/dev/null)" ]; then
|
||||
echo "❌ 错误:backend/build/libs/*.jar 不存在"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ 构建产物已准备好"
|
||||
ls -lh frontend/dist/ | head -5
|
||||
ls -lh backend/build/libs/*.jar
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
@@ -101,15 +278,18 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
wrbug/polyhermes:${{ steps.extract_version.outputs.TAG }}
|
||||
wrbug/polyhermes:latest
|
||||
${{ steps.extract_version.outputs.IS_PRERELEASE == 'false' && 'wrbug/polyhermes:latest' || '' }}
|
||||
build-args: |
|
||||
BUILD_IN_DOCKER=false
|
||||
VERSION=${{ steps.extract_version.outputs.VERSION }}
|
||||
GIT_TAG=${{ steps.extract_version.outputs.TAG }}
|
||||
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||
cache-from: type=registry,ref=wrbug/polyhermes:latest
|
||||
cache-to: type=inline
|
||||
|
||||
- name: Send Telegram notification
|
||||
|
||||
# ============ 发送 Docker 构建成功通知 ============
|
||||
- name: Send Telegram notification (Docker build completed)
|
||||
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
@@ -123,13 +303,15 @@ jobs:
|
||||
# 获取构建信息
|
||||
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||
RELEASE_NAME="${{ github.event.release.name }}"
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
REPO_NAME="${{ github.repository }}"
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
|
||||
MESSAGE="✅ <b>Docker 镜像构建成功</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
|
||||
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
MESSAGE="🐳 <b>Docker Image Built Successfully</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"📦 <b>Image:</b> <code>wrbug/polyhermes:${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">View Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker Deployment Guide</a>"
|
||||
else
|
||||
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
MESSAGE="🐳 <b>Docker Image Built Successfully</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"📦 <b>Image:</b> <code>wrbug/polyhermes:${TAG}</code>"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">View Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker Deployment Guide</a>"
|
||||
fi
|
||||
|
||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
@@ -146,11 +328,9 @@ jobs:
|
||||
echo "✅ Telegram 通知发送成功"
|
||||
else
|
||||
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||
# 构建成功,通知失败不应该导致整个 job 失败
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
echo "❌ 发送 Telegram 消息时发生错误"
|
||||
# 构建成功,通知失败不应该导致整个 job 失败
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
name: Telegram Notification on PR Merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed # 当 PR 被关闭(合并或关闭)时触发
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# 只在 PR 被合并到 main 分支时执行
|
||||
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get PR details
|
||||
id: pr_details
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
# 获取 PR 详细信息
|
||||
PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}")
|
||||
|
||||
# 获取 PR 变更的文件列表
|
||||
FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files")
|
||||
|
||||
# 提取 PR 描述(body),保留换行,限制长度
|
||||
PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""')
|
||||
if [ ${#PR_BODY} -gt 500 ]; then
|
||||
PR_BODY="${PR_BODY:0:500}..."
|
||||
fi
|
||||
|
||||
# 保存到输出变量(使用 base64 编码避免特殊字符问题)
|
||||
echo "pr_body<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$PR_BODY" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Send Telegram notification
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
run: |
|
||||
# 检查 PR 是否被合并(而不是仅关闭)
|
||||
PR_MERGED="${{ github.event.pull_request.merged }}"
|
||||
if [ "$PR_MERGED" != "true" ]; then
|
||||
echo "ℹ️ PR 仅关闭,未合并,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查必要的环境变量
|
||||
# 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890)
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 获取 PR 基本信息
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_URL="${{ github.event.pull_request.html_url }}"
|
||||
PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
|
||||
# 获取 PR 详细信息
|
||||
PR_BODY="${{ steps.pr_details.outputs.pr_body }}"
|
||||
|
||||
# 转义 PR 标题中的 HTML 特殊字符
|
||||
PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&/g' | sed 's/</\</g' | sed 's/>/\>/g')
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
MESSAGE="🚀 <b>main 分支代码更新</b>"$'\n'$'\n'"📝 <b>PR #${PR_NUMBER}:</b> ${PR_TITLE_ESCAPED}"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
|
||||
|
||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||
--arg text "$MESSAGE" \
|
||||
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
|
||||
|
||||
# 检查发送结果
|
||||
if [ $? -eq 0 ]; then
|
||||
RESPONSE=$(cat /tmp/telegram_response.json)
|
||||
if echo "$RESPONSE" | grep -q '"ok":true'; then
|
||||
echo "✅ Telegram 通知发送成功"
|
||||
else
|
||||
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ 发送 Telegram 消息时发生错误"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+4
-2
@@ -17,14 +17,16 @@ backend/out/
|
||||
backend/*.log
|
||||
backend/gradle-app.setting
|
||||
backend/.gradle
|
||||
backend/gradle-wrapper.jar
|
||||
|
||||
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
|
||||
# backend/gradle/wrapper/gradle-wrapper.jar
|
||||
polyhub/
|
||||
# Kotlin
|
||||
*.kt.bak
|
||||
*.class
|
||||
|
||||
# Java
|
||||
*.jar
|
||||
!backend/gradle/wrapper/gradle-wrapper.jar # Gradle Wrapper JAR 应该被提交
|
||||
*.war
|
||||
*.ear
|
||||
*.class
|
||||
|
||||
+88
-31
@@ -1,6 +1,12 @@
|
||||
# 多阶段构建:前后端一体化部署
|
||||
# 阶段1:构建前端
|
||||
# 多阶段构建:前后端一体化部署(支持混合编译)
|
||||
# 构建参数:控制是否在 Docker 内编译
|
||||
# - BUILD_IN_DOCKER=true (默认): Docker 内部编译(本地开发)
|
||||
# - BUILD_IN_DOCKER=false: 使用外部产物(GitHub Actions)
|
||||
ARG BUILD_IN_DOCKER=true
|
||||
|
||||
# ==================== 阶段1:构建前端 ====================
|
||||
FROM node:18-alpine AS frontend-build
|
||||
ARG BUILD_IN_DOCKER
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
@@ -13,19 +19,37 @@ ARG GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||
ENV VERSION=${VERSION}
|
||||
ENV GIT_TAG=${GIT_TAG}
|
||||
ENV GITHUB_REPO_URL=${GITHUB_REPO_URL}
|
||||
|
||||
# 复制前端文件
|
||||
# 复制前端文件(先复制 package.json 以利用 Docker 缓存)
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# 条件:仅在 Docker 内部编译时安装依赖
|
||||
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||
npm ci; \
|
||||
fi
|
||||
|
||||
# 复制所有前端源文件
|
||||
COPY frontend/ ./
|
||||
|
||||
# 构建前端(使用相对路径,通过 Nginx 代理)
|
||||
# 版本号会通过环境变量注入到构建产物中
|
||||
RUN npm run build
|
||||
# 条件:仅在 Docker 内部编译时执行构建
|
||||
# 如果 BUILD_IN_DOCKER=false,需要确保构建上下文中存在 frontend/dist
|
||||
# 注意:COPY frontend/ ./ 已经复制了整个 frontend 目录(包括 dist,如果存在)
|
||||
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||
echo "🔨 Docker 内部编译前端..."; \
|
||||
npm run build; \
|
||||
else \
|
||||
echo "⏭️ 使用外部产物..."; \
|
||||
if [ ! -d "dist" ] || [ -z "$(ls -A dist 2>/dev/null)" ]; then \
|
||||
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 frontend/dist"; \
|
||||
echo " 请先执行: cd frontend && npm install && npm run build"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "✅ 找到外部构建的前端产物"; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# 阶段2:构建后端
|
||||
# ==================== 阶段2:构建后端 ====================
|
||||
FROM gradle:8.5-jdk17 AS backend-build
|
||||
ARG BUILD_IN_DOCKER
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
@@ -33,60 +57,93 @@ WORKDIR /app/backend
|
||||
COPY backend/build.gradle.kts backend/settings.gradle.kts ./
|
||||
COPY backend/gradle ./gradle
|
||||
|
||||
# 下载依赖(利用 Docker 缓存)
|
||||
RUN gradle dependencies --no-daemon || true
|
||||
# 条件:仅在 Docker 内部编译时下载依赖
|
||||
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||
gradle dependencies --no-daemon || true; \
|
||||
fi
|
||||
|
||||
# 复制源代码
|
||||
COPY backend/src ./src
|
||||
|
||||
# 构建应用
|
||||
RUN gradle bootJar --no-daemon
|
||||
# 尝试复制外部构建的 JAR(如果存在)
|
||||
# 注意:COPY 指令如果源不存在会失败
|
||||
# GitHub Actions 使用 BUILD_IN_DOCKER=false,会先构建产物,所以 backend/build 应该存在
|
||||
# 本地开发使用 BUILD_IN_DOCKER=true,会在 Docker 内编译,所以 backend/build 可能不存在
|
||||
# 解决方案:先复制整个 backend 目录(包括 build,如果存在),然后只使用需要的部分
|
||||
# 使用 .dockerignore 确保不会复制不需要的文件(如 .gradle、out、bin 等)
|
||||
COPY backend/build ./build-external
|
||||
|
||||
# 阶段3:运行环境
|
||||
# 处理外部构建的 JAR(如果存在)
|
||||
RUN if [ -d "build-external/libs" ] && [ -n "$(ls -A build-external/libs/*.jar 2>/dev/null)" ]; then \
|
||||
echo "📦 找到外部构建的后端产物,复制到 build/libs..."; \
|
||||
mkdir -p build/libs; \
|
||||
cp build-external/libs/*.jar build/libs/; \
|
||||
rm -rf build-external; \
|
||||
else \
|
||||
echo "⏭️ 未找到外部构建的 JAR,将在 Docker 内编译"; \
|
||||
rm -rf build-external; \
|
||||
mkdir -p build/libs; \
|
||||
fi
|
||||
|
||||
# 条件:仅在 Docker 内部编译时执行构建(会覆盖外部产物)
|
||||
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||
echo "🔨 Docker 内部编译后端..."; \
|
||||
gradle bootJar --no-daemon; \
|
||||
else \
|
||||
echo "⏭️ 使用外部产物"; \
|
||||
if [ -z "$(ls -A build/libs/*.jar 2>/dev/null)" ]; then \
|
||||
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 backend/build/libs/*.jar"; \
|
||||
echo " 请先执行: cd backend && ./gradlew bootJar"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "✅ 使用外部构建的后端产物"; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# ==================== 阶段3:运行环境 ====================
|
||||
FROM eclipse-temurin:17-jre-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 Nginx 和必要的工具(包含时区数据)
|
||||
# 安装 Nginx、Python 和必要的工具
|
||||
RUN apt-get update && \
|
||||
apt-get install -y nginx curl tzdata && \
|
||||
apt-get install -y nginx curl tzdata jq python3 python3-flask python3-requests && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
rm -rf /etc/nginx/sites-enabled/default
|
||||
|
||||
# 从构建阶段复制文件
|
||||
# 当 BUILD_IN_DOCKER=false 时,构建阶段已经复制了外部产物
|
||||
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
|
||||
COPY --from=backend-build /app/backend/build/libs/*.jar app.jar
|
||||
|
||||
# 复制 Nginx 配置
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# 创建启动脚本
|
||||
# 创建更新服务相关目录和脚本
|
||||
RUN mkdir -p /app/updates /app/backups /var/log/polyhermes
|
||||
COPY docker/update-service.py /app/update-service.py
|
||||
COPY docker/start.sh /app/start.sh
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
# 创建非 root 用户(用于运行后端应用)
|
||||
# 记录初始版本(从构建参数)
|
||||
ARG VERSION=dev
|
||||
ARG GIT_TAG=dev
|
||||
RUN echo "{\"version\":\"${VERSION}\",\"tag\":\"${GIT_TAG}\",\"buildTime\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > /app/version.json
|
||||
|
||||
# 创建非 root 用户
|
||||
RUN useradd -m -u 1000 appuser
|
||||
|
||||
# 设置目录权限(Nginx 以 root 运行,后端应用以 appuser 运行)
|
||||
# 设置目录权限
|
||||
RUN mkdir -p /var/log/nginx /var/lib/nginx /var/cache/nginx /var/run && \
|
||||
chown -R appuser:appuser /app && \
|
||||
chown -R root:root /usr/share/nginx/html && \
|
||||
chown -R root:root /var/log/nginx && \
|
||||
chown -R root:root /var/lib/nginx && \
|
||||
chown -R root:root /var/cache/nginx && \
|
||||
chown -R root:root /etc/nginx && \
|
||||
chown -R root:root /var/run
|
||||
|
||||
# 保持 root 用户(Nginx 需要 root 权限绑定 80 端口)
|
||||
# USER appuser
|
||||
chown -R root:root /usr/share/nginx/html /var/log/nginx /var/lib/nginx /var/cache/nginx /etc/nginx /var/run
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 80
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost/api/health || exit 1
|
||||
CMD curl -f http://localhost/api/system/health || exit 1
|
||||
|
||||
# 启动服务(同时启动 Nginx 和后端)
|
||||
# 启动服务
|
||||
ENTRYPOINT ["/app/start.sh"]
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
- **API 健康检查**:实时监控 Polymarket API 的健康状态
|
||||
- **用户管理**:管理系统用户,支持添加、编辑、删除用户
|
||||
- **公告管理**:查看系统公告和更新信息
|
||||
- **动态更新**:支持在线更新系统版本,无需重新部署容器
|
||||
|
||||
### 🚀 技术特性
|
||||
|
||||
@@ -164,6 +165,29 @@
|
||||
|
||||
**部署步骤**:
|
||||
|
||||
#### ⚡ 一键安装(最快,推荐新用户)
|
||||
|
||||
**使用 curl(推荐):**
|
||||
```bash
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
|
||||
```
|
||||
|
||||
**使用 wget:**
|
||||
```bash
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
|
||||
```
|
||||
|
||||
这个命令会自动:
|
||||
- 📁 创建专用工作目录 `~/polyhermes`
|
||||
- ✅ 自动检查 Docker 环境
|
||||
- ⚙️ 交互式配置所有参数(支持回车使用默认值)
|
||||
- 🔐 自动生成安全的随机密钥
|
||||
- 🚀 自动下载最新镜像并部署
|
||||
|
||||
---
|
||||
|
||||
#### 📋 其他部署方式
|
||||
|
||||
1. **使用 Docker Hub 镜像(推荐,生产环境首选)**
|
||||
|
||||
**方式 1:独立部署(无需 clone 代码,推荐)**
|
||||
@@ -397,6 +421,7 @@ cd frontend
|
||||
- [开发文档](docs/zh/DEVELOPMENT.md) - 开发指南
|
||||
- [跟单系统需求文档](docs/zh/copy-trading-requirements.md) - 后端 API 接口文档
|
||||
- [前端需求文档](docs/zh/copy-trading-frontend-requirements.md) - 前端功能文档
|
||||
- [动态更新文档](docs/zh/DYNAMIC_UPDATE.md) - 动态更新功能说明
|
||||
|
||||
### 🤝 贡献指南
|
||||
|
||||
|
||||
@@ -164,6 +164,29 @@ Deploy both frontend and backend together in a single Docker container, using Ng
|
||||
|
||||
**Deployment Steps**:
|
||||
|
||||
#### ⚡ One-Liner Installation (Fastest, Recommended for New Users)
|
||||
|
||||
**Using curl (Recommended):**
|
||||
```bash
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
|
||||
```
|
||||
|
||||
**Using wget:**
|
||||
```bash
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
|
||||
```
|
||||
|
||||
This command will automatically:
|
||||
- 📁 Create dedicated working directory `~/polyhermes`
|
||||
- ✅ Automatically check Docker environment
|
||||
- ⚙️ Interactive configuration for all parameters (supports pressing Enter for defaults)
|
||||
- 🔐 Automatically generate secure random keys
|
||||
- 🚀 Automatically download latest images and deploy
|
||||
|
||||
---
|
||||
|
||||
#### 📋 Alternative Deployment Methods
|
||||
|
||||
1. **Using Docker Hub Images (Recommended, Production First Choice)**
|
||||
|
||||
**Method 1: Standalone Deployment (No code clone required, Recommended)**
|
||||
|
||||
-971
@@ -1,971 +0,0 @@
|
||||
# v1.1.10
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 📢 推送已过滤订单功能
|
||||
|
||||
- **新增推送已过滤订单功能(pushFilteredOrders)**,默认关闭
|
||||
- 支持在模板和跟单配置中配置是否推送被过滤的订单通知
|
||||
- 开启后,当订单因过滤条件(价格区间、订单深度、价差、仓位限制等)被过滤时,会发送 Telegram 通知
|
||||
- 帮助用户了解哪些订单被过滤以及过滤原因
|
||||
|
||||
- **数据库迁移**:
|
||||
- 添加 `push_filtered_orders` 字段到 `copy_trading_templates` 表
|
||||
- 添加 `push_filtered_orders` 字段到 `copy_trading` 表
|
||||
- 迁移脚本:`V24__add_push_filtered_orders_to_templates.sql`
|
||||
|
||||
- **后端实现**:
|
||||
- 在实体类(`CopyTradingTemplate`、`CopyTrading`)中添加 `pushFilteredOrders` 字段
|
||||
- 在 DTO 中添加字段支持(创建、更新、查询)
|
||||
- 在 Service 中处理字段的创建、更新和传递
|
||||
- 在发送过滤订单通知时检查 `pushFilteredOrders` 字段,只有为 `true` 时才发送
|
||||
|
||||
- **前端实现**:
|
||||
- 在模板新增、编辑、推送页面添加"推送已过滤订单"开关
|
||||
- 在跟单配置新增、编辑页面添加"推送已过滤订单"开关
|
||||
- 从模板创建跟单配置时,自动填充 `pushFilteredOrders` 字段
|
||||
- 支持多语言(中文、繁体中文、英文)
|
||||
|
||||
### 🔍 优化订单列表筛选功能
|
||||
|
||||
- **支持按市场标题搜索**:
|
||||
- 买入订单列表:添加市场标题筛选,移除方向筛选
|
||||
- 卖出订单列表:添加市场标题筛选,将方向筛选改为状态筛选
|
||||
- 已成交订单列表:添加市场标题筛选和市场列显示
|
||||
|
||||
- **改进分组体验**:
|
||||
- 记录用户的分组偏好到 localStorage,跨会话持久化
|
||||
- 所有搜索输入框添加 0.5 秒防抖优化,提升性能
|
||||
|
||||
- **后端优化**:
|
||||
- `MarketGroupedOrdersRequest` 添加 `marketId` 和 `marketTitle` 字段
|
||||
- 买入/卖出订单分组接口支持市场 ID 模糊匹配和市场标题关键字筛选
|
||||
|
||||
### ⚡ 优化跟单关系统计性能
|
||||
|
||||
- **移除未实现盈亏计算**:
|
||||
- 移除未实现盈亏和持仓价值的计算逻辑
|
||||
- 总盈亏现在仅包含已实现盈亏,计算更准确
|
||||
- 简化盈亏百分比计算,仅基于已实现盈亏
|
||||
|
||||
- **性能提升**:
|
||||
- 从 1+N 次网络请求减少到 0 次
|
||||
- 删除不再使用的方法和依赖注入
|
||||
- 统计查询速度显著提升
|
||||
|
||||
### 🐳 Docker 容器时区配置
|
||||
|
||||
- **支持通过 .env 自定义时区**:
|
||||
- 在 `docker-compose.yml` 和 `docker-compose.prod.yml` 中添加时区环境变量配置
|
||||
- 支持通过 `TZ` 环境变量自定义容器时区
|
||||
- 默认使用系统时区
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复跟单配置更新时清空可选字段无法保存的问题
|
||||
|
||||
- **问题**:修改跟单配置时,清空价格区间、最大仓位数量、截止时间等可选字段后,无法保存到数据库
|
||||
- **修复**:
|
||||
- 修复价格区间(`minPrice`/`maxPrice`)清空后无法保存的问题
|
||||
- 修复最大仓位数量(`maxPositionCount`)清空后无法保存的问题
|
||||
- 修复市场截止时间(`maxMarketEndDate`)清空后无法保存的问题
|
||||
- 修复其他可选字段(`minOrderDepth`/`maxSpread`/`maxPositionValue`)清空后无法保存的问题
|
||||
|
||||
- **实现方案**:
|
||||
- 前端:清空字段时传空字符串或 `-1` 标记,让后端识别为清空操作
|
||||
- 后端:处理空字符串和 `-1` 标记,正确设置为 `null` 以清空字段
|
||||
|
||||
### 修复新建和编辑页面的截止时间输入框交互问题
|
||||
|
||||
- **问题**:删除截止时间输入框内容后,失去焦点会自动填充 1
|
||||
- **修复**:
|
||||
- 将 `min` 从 `1` 改为 `0`,允许空值
|
||||
- 优化 `onChange` 处理,当值为 `0`、`null` 或 `undefined` 时设置为 `undefined`(清空)
|
||||
- 添加 `onBlur` 处理,确保失去焦点时如果值为空或 `0`,设置为 `undefined`
|
||||
- 修改 `parser`,空值时返回空字符串而不是 `0`
|
||||
|
||||
### 修复订单通知重复发送和时间显示问题
|
||||
|
||||
- **修复并发导致的重复通知问题**:
|
||||
- 在 `OrderStatusUpdateService` 中实现双重检查机制
|
||||
- 先保存订单标记为已发送,再重新查询数据库检查
|
||||
- 防止定时任务并发时重复发送同一订单的通知
|
||||
- 同时修复买入订单和卖出订单的通知逻辑
|
||||
|
||||
- **修复 Telegram 通知时间显示**:
|
||||
- `TelegramNotificationService.sendOrderSuccessNotification` 添加 `orderTime` 参数
|
||||
- 使用订单的 `createdAt` 时间戳作为通知显示时间
|
||||
- 而不是使用当前通知发送时间
|
||||
- 更准确反映订单的实际创建时间
|
||||
|
||||
### 修复新建跟单配置时 pushFilteredOrders 字段未生效的问题
|
||||
|
||||
- **问题**:新建跟单配置时,即使设置了 `pushFilteredOrders: true`,也没有生效
|
||||
- **修复**:
|
||||
- 修复手动输入模式下 `pushFilteredOrders` 被硬编码为 `false` 的问题
|
||||
- 修复从模板填充表单时未加载 `pushFilteredOrders` 的问题
|
||||
- 添加 `CopyTradingTemplate` 接口的 `pushFilteredOrders` 字段定义
|
||||
|
||||
## 📝 技术细节
|
||||
|
||||
### 数据库变更
|
||||
|
||||
- **迁移脚本**:`V24__add_push_filtered_orders_to_templates.sql`
|
||||
- **变更内容**:
|
||||
- `copy_trading_templates.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- `copy_trading.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- **自动执行**:升级时会自动执行迁移脚本
|
||||
|
||||
### API 变更
|
||||
|
||||
- **无新增接口**
|
||||
- **无移除接口**
|
||||
- **请求/响应变更**:
|
||||
- `CopyTradingCreateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `CopyTradingUpdateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `TemplateCreateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `TemplateUpdateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `MarketGroupedOrdersRequest` 添加 `marketId` 和 `marketTitle` 字段
|
||||
|
||||
### 前端变更
|
||||
|
||||
- **新增字段**:
|
||||
- `CopyTradingTemplate` 接口添加 `pushFilteredOrders` 字段
|
||||
- **组件更新**:
|
||||
- 模板新增、编辑、推送页面添加"推送已过滤订单"开关
|
||||
- 跟单配置新增、编辑页面添加"推送已过滤订单"开关
|
||||
- 优化截止时间输入框交互逻辑
|
||||
- **多语言支持**:
|
||||
- 添加中文、繁体中文、英文翻译
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:6 个提交
|
||||
- **文件变更**:30 个文件
|
||||
- **代码变更**:+651 行 / -555 行(净增加 96 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `CopyTrading.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
|
||||
- `CopyTradingTemplate.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
|
||||
- `CopyTradingDto.kt` - 添加 `pushFilteredOrders` 字段支持(+3 行)
|
||||
- `CopyTradingTemplateDto.kt` - 添加 `pushFilteredOrders` 字段支持(+10 行)
|
||||
- `CopyTradingService.kt` - 处理 `pushFilteredOrders` 字段和清空字段逻辑(+93 行)
|
||||
- `CopyTradingTemplateService.kt` - 处理 `pushFilteredOrders` 字段(+8 行)
|
||||
- `CopyOrderTrackingService.kt` - 检查 `pushFilteredOrders` 字段发送通知(+42 行)
|
||||
- `CopyTradingStatisticsService.kt` - 优化统计性能(-328 行)
|
||||
- `OrderStatusUpdateService.kt` - 修复重复通知问题(+142 行)
|
||||
- `TelegramNotificationService.kt` - 添加订单时间参数(+17 行)
|
||||
- `V24__add_push_filtered_orders_to_templates.sql` - 数据库迁移脚本(+13 行)
|
||||
|
||||
**前端变更**:
|
||||
- `AddModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+40 行)
|
||||
- `EditModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+59 行)
|
||||
- `TemplateAdd.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
|
||||
- `TemplateEdit.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
|
||||
- `TemplateList.tsx` - 添加 `pushFilteredOrders` 字段(+13 行)
|
||||
- `BuyOrdersTab.tsx` - 优化筛选功能(+94 行)
|
||||
- `SellOrdersTab.tsx` - 优化筛选功能(+77 行)
|
||||
- `MatchedOrdersTab.tsx` - 优化筛选功能(+26 行)
|
||||
- `types/index.ts` - 添加 `pushFilteredOrders` 字段定义(+6 行)
|
||||
- `locales/*/common.json` - 添加多语言翻译(+31 行)
|
||||
|
||||
**配置文件变更**:
|
||||
- `docker-compose.yml` - 添加时区配置(+5 行)
|
||||
- `docker-compose.prod.yml` - 添加时区配置(+5 行)
|
||||
- `Dockerfile` - 优化构建配置(+4 行)
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
9c303e0 feat: 添加推送已过滤订单功能并修复相关问题
|
||||
cb8e469 fix: 修复跟单配置更新时清空可选字段无法保存的问题
|
||||
279806d feat: 优化订单列表筛选功能,支持按市场标题搜索并改进分组体验
|
||||
90fa487 refactor: 移除未实现盈亏计算以优化跟单关系统计性能
|
||||
b58bb26 feat: 添加 Docker 容器时区配置,支持通过 .env 自定义
|
||||
6af76c4 fix: 修复订单通知重复发送和时间显示问题
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:本次版本包含数据库迁移脚本,升级时会自动执行
|
||||
- 自动添加 `push_filtered_orders` 字段到模板表和跟单配置表
|
||||
- 现有数据不受影响,新字段默认值为 `false`
|
||||
|
||||
2. **配置更新**:
|
||||
- 可选:在 `.env` 文件中添加 `TZ` 环境变量自定义容器时区
|
||||
- 无需其他配置变更
|
||||
|
||||
3. **兼容性**:
|
||||
- 完全向后兼容,不影响现有功能
|
||||
- API 变更都是新增字段,不影响现有调用
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.10`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.10)
|
||||
- [完整更新日志](https://github.com/WrBug/PolyHermes/compare/v1.1.9...v1.1.10)
|
||||
|
||||
---
|
||||
|
||||
**发布日期**:2026-01-12
|
||||
|
||||
---
|
||||
|
||||
# v1.1.9
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复盈亏统计计算错误问题
|
||||
|
||||
- **问题**:在仓位检查时,刚创建的订单可能因仓位更新延迟被误判为已卖出,导致盈亏统计计算错误
|
||||
- **修复**:
|
||||
- 优化自动卖出订单筛选逻辑,在 SQL 层直接过滤创建时间超过阈值的订单
|
||||
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` Repository 方法,提高查询效率
|
||||
- 统一使用 2 分钟阈值保护刚创建的订单,避免误判
|
||||
- 两个场景都使用 SQL 过滤:
|
||||
* 场景1:仓位不存在时,延迟检测使用 SQL 过滤
|
||||
* 场景2:仓位部分存在时,FIFO 匹配使用 SQL 过滤
|
||||
|
||||
- **优化效果**:
|
||||
- SQL 层面直接过滤,减少数据传输,提高查询效率
|
||||
- 代码更简洁,逻辑更清晰
|
||||
- 刚创建的订单(< 2 分钟)不会被误判为已卖出
|
||||
- 确保盈亏统计计算的准确性
|
||||
|
||||
## 📝 技术细节
|
||||
|
||||
- **Repository 变更**:
|
||||
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` 方法
|
||||
- 在 SQL 查询中添加 `createdAt < :thresholdTime` 条件过滤
|
||||
|
||||
- **Service 变更**:
|
||||
- `PositionCheckService` 中两个场景都使用 SQL 过滤替代内存过滤
|
||||
- 统一使用 2 分钟(120000 毫秒)作为时间阈值
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:1 个提交
|
||||
- **文件变更**:2 个文件
|
||||
- **代码变更**:+57 行 / -19 行(净增加 38 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `CopyOrderTrackingRepository.kt` - 新增 SQL 查询方法(+18 行)
|
||||
- `PositionCheckService.kt` - 优化订单筛选逻辑(+39 行 / -19 行)
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
6ad4024 fix: 优化自动卖出订单筛选逻辑,避免刚创建的订单被误判
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:无需数据库迁移,可直接升级
|
||||
2. **配置更新**:无需配置变更
|
||||
3. **兼容性**:完全向后兼容,不影响现有功能
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.9`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.9)
|
||||
|
||||
---
|
||||
|
||||
# v1.1.8
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### ⚡ Polymarket Activity WebSocket 双重监听方案
|
||||
|
||||
- **新增 PolymarketActivityWsService**:通过 Activity WebSocket 实时监听 Leader 交易
|
||||
- 延迟 < 100ms,适合快速跟单场景
|
||||
- 订阅全局 activity 交易流,客户端过滤 Leader 地址
|
||||
- 支持动态添加/移除 Leader 监听
|
||||
- 地址筛选忽略大小写,提高匹配准确性
|
||||
|
||||
- **双重监听机制**:
|
||||
- Activity WebSocket(低延迟,< 100ms):作为主要监听方式
|
||||
- On-Chain WebSocket(高可靠性,~2-3s):作为兜底监听方式
|
||||
- 自动去重,避免重复处理同一笔交易
|
||||
|
||||
- **协议支持**:
|
||||
- 支持 `connection_id` 字段
|
||||
- 优先使用消息中的 `outcomeIndex` 字段,fallback 到从 `outcome` 解析
|
||||
|
||||
- **参考文档**:
|
||||
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
|
||||
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
|
||||
|
||||
### 🔧 支持同一用户创建多个相同 Leader 的跟单配置
|
||||
|
||||
- **移除唯一约束**:允许同一用户创建多个跟单同一个 Leader 的配置
|
||||
- **应用场景**:支持不同参数(比例、过滤条件等)的多配置跟单
|
||||
- **数据库迁移**:`V23__remove_unique_constraint_from_copy_trading.sql`
|
||||
|
||||
### 🎯 市场截止时间筛选功能
|
||||
|
||||
- **新增市场截止时间过滤**:支持设置跟单配置的最大市场截止时间
|
||||
- **自动过滤过期市场**:系统会自动跳过超过设定截止时间的市场
|
||||
- **数据库字段**:`max_market_end_date`(时间戳,单位:秒)
|
||||
- **数据库迁移**:`V22__add_max_market_end_date_to_copy_trading.sql`
|
||||
|
||||
### 🔍 关键字过滤功能
|
||||
|
||||
- **新增关键字过滤**:支持在黑名单或白名单模式下过滤市场标题关键字
|
||||
- **过滤模式**:
|
||||
- `DISABLED`:禁用关键字过滤
|
||||
- `BLACKLIST`:黑名单模式(包含关键字的市场会被过滤)
|
||||
- `WHITELIST`:白名单模式(只允许包含关键字的市场)
|
||||
- **多关键字支持**:支持多个关键字,以 JSON 数组格式存储
|
||||
- **数据库字段**:`keyword_filter_mode` 和 `keywords`(JSON 数组)
|
||||
- **数据库迁移**:`V20__add_keyword_filter.sql`
|
||||
|
||||
### 📊 订单列表功能重构
|
||||
|
||||
- **按市场分组显示**:订单列表按市场分组,便于查看和管理
|
||||
- **市场信息展示**:
|
||||
- 显示市场标题和 slug
|
||||
- 支持跳转到 Polymarket 市场页面
|
||||
- 显示市场基本信息(标题、slug、截止时间等)
|
||||
- **订单 ID 复制功能**:一键复制订单 ID,方便查询和调试
|
||||
- **UI/UX 优化**:
|
||||
- 优化订单列表展示样式
|
||||
- 改进移动端适配
|
||||
|
||||
### 📈 市场信息管理和缓存优化
|
||||
|
||||
- **新增 MarketService**:统一管理市场信息查询和缓存
|
||||
- 使用 LRU 缓存提高查询性能
|
||||
- 支持从 Gamma API 和 CLOB API 查询市场信息
|
||||
- 自动缓存市场信息,减少 API 调用
|
||||
- **新增 Market 实体和表**:持久化存储市场信息
|
||||
- 存储市场标题、slug、eventSlug、endDate 等基本信息
|
||||
- 支持通过 marketId 快速查询市场信息
|
||||
- **数据库迁移**:
|
||||
- `V19__create_markets_table.sql`:创建市场信息表
|
||||
- `V21__add_event_slug_to_markets.sql`:添加 eventSlug 字段
|
||||
- **市场信息轮询服务**:定期更新市场信息,保持数据新鲜度
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 前端 TypeScript 类型错误修复
|
||||
|
||||
- **修复 InputNumber parser 类型错误**:`parser` 函数应返回 `number` 而不是 `string`
|
||||
- **修复文件**:
|
||||
- `src/pages/CopyTradingOrders/AddModal.tsx`
|
||||
- `src/pages/CopyTradingOrders/EditModal.tsx`
|
||||
|
||||
## 📝 文档更新
|
||||
|
||||
- **新增双重监听方案文档**:详细说明 Activity WebSocket 和 On-Chain WebSocket 的双重监听机制
|
||||
- **新增 Activity WebSocket API 文档**:详细的 API 格式和消息结构说明
|
||||
- **更新开发规范**:JSON 解析规范和 Data Class 规范
|
||||
|
||||
## 📊 统计信息
|
||||
|
||||
- **61 个文件被修改**
|
||||
- **+5125 行新增代码**
|
||||
- **-1942 行删除代码**
|
||||
- **净增加 3183 行代码**
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
d376a82 feat: 添加市场信息管理和订单ID复制功能
|
||||
2af2c0e feat: 订单列表按市场分组并支持跳转到Polymarket
|
||||
9ed5190 feat: 添加关键字过滤功能并优化市场 slug 处理
|
||||
0327eaf feat: 添加市场截止时间筛选功能
|
||||
a16b6fc feat: 支持同一用户创建多个相同 leader 的跟单配置
|
||||
19508dc feat: 实现 Polymarket Activity WebSocket 双重监听方案
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:确保执行所有数据库迁移脚本(V19-V23)
|
||||
2. **配置更新**:新版本的 `application.properties` 中添加了 Activity WebSocket URL 配置
|
||||
3. **环境变量**:如果使用 Docker 部署,建议更新 `docker-compose.yml` 中的配置
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.8`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.8)
|
||||
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
|
||||
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
|
||||
|
||||
---
|
||||
|
||||
# v1.1.7
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 💰 Polymarket Maker Rebates Program 费率支持
|
||||
|
||||
- **新增费率查询 API 接口** (`getFeeRate`)
|
||||
- 支持动态查询 Maker Rebates Program 费率
|
||||
- 修正 API 返回字段名:使用 `base_fee` 而非 `fee_rate_bps`(与 TypeScript clob-client 一致)
|
||||
|
||||
- **动态费率获取**
|
||||
- 在所有订单创建处动态获取费率:
|
||||
* 跟单买入订单 (`processBuyTrade`)
|
||||
* 跟单卖出订单 (`matchSellOrder`)
|
||||
* 账户卖出订单 (`sellPosition`)
|
||||
- 费率获取失败时降级到默认值 "0",确保系统可用性
|
||||
- 添加详细的日志记录,便于监控和调试
|
||||
|
||||
- **参考文档**: https://docs.polymarket.com/developers/market-makers/maker-rebates-program
|
||||
|
||||
### 🔧 Docker 部署优化
|
||||
|
||||
- **日志级别环境变量支持**
|
||||
- 在 `application.properties` 中支持通过 `LOG_LEVEL_ROOT` 和 `LOG_LEVEL_APP` 环境变量配置日志级别
|
||||
- 在 `docker-compose.yml` 和 `docker-compose.prod.yml` 中添加日志级别环境变量配置
|
||||
- 在 `deploy.sh` 的 `.env` 模板中添加日志级别配置说明
|
||||
- 支持通过环境变量动态配置日志级别,无需修改配置文件
|
||||
- 默认值:`root=INFO`, `app=DEBUG`
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复市场条件查询的 RPC 调用错误
|
||||
|
||||
- **问题**:使用错误的函数签名 `conditions(bytes32)` 导致 RPC 调用失败(execution reverted)
|
||||
- **修复**:
|
||||
- 将错误的 `conditions(bytes32)` 函数调用改为正确的 `getOutcomeSlotCount(bytes32)` 和 `payoutDenominator(bytes32)` 函数调用
|
||||
- 修复 `BlockchainService.getCondition` 方法,使用正确的 ConditionalTokens 合约函数签名
|
||||
- 改进 `MarketPriceService` 的错误处理:当链上查询出现 RPC 错误时,降级到 CLOB API 或 Gamma API 查询,而不是直接抛出异常,提高容错性
|
||||
|
||||
### 修复 RPC 错误时误创建自动卖出记录的问题
|
||||
|
||||
- **问题**:当链上查询市场条件出现 RPC 错误(execution reverted)时,系统会误判为市场已卖出,创建错误的自动卖出记录
|
||||
- **修复**:
|
||||
- 修改 `getPriceFromChainCondition` 返回 `Pair<BigDecimal?, Boolean>`,第二个值表示是否发生 RPC 错误
|
||||
- 在 `getCurrentMarketPrice` 中检测到 RPC 错误时抛出异常,`PositionCheckService` 会捕获并跳过该市场的处理
|
||||
- 避免在市场不存在或尚未创建时误判为已卖出
|
||||
|
||||
## 📝 文档更新
|
||||
|
||||
### 更新 Telegram 群链接
|
||||
|
||||
- 将所有 Telegram 群链接统一更新为 `t.me/polyhermes`
|
||||
- 更新了以下文件:
|
||||
- `frontend/src/components/Layout.tsx` - 桌面端和移动端导航链接
|
||||
- `RELEASE.md` - 相关链接
|
||||
- `README.md` 和 `README_EN.md` - 相关链接部分
|
||||
|
||||
### 添加 Docker 版本徽章
|
||||
|
||||
- 在 README 和 README_EN.md 中添加动态 Docker 版本徽章
|
||||
- 使用 shields.io 自动显示 Docker Hub 上 `wrbug/polyhermes` 镜像的最新版本
|
||||
- 版本信息自动更新,无需手动维护
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:5 个提交
|
||||
- **文件变更**:16 个文件
|
||||
- **代码变更**:+205 行 / -886 行(净减少 681 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `PolymarketClobApi.kt` - 添加费率查询接口(+25 行)
|
||||
- `AccountService.kt` - 在订单创建处添加动态费率获取(+11 行)
|
||||
- `BlockchainService.kt` - 修复市场条件查询的 RPC 调用错误(+84 行)
|
||||
- `MarketPriceService.kt` - 改进错误处理,支持降级到其他数据源(+36 行)
|
||||
- `PolymarketClobService.kt` - 添加费率查询服务(+32 行)
|
||||
- `CopyOrderTrackingService.kt` - 在跟单订单创建处添加费率获取(+34 行)
|
||||
- `PositionCheckService.kt` - 修复 RPC 错误处理逻辑(+2 行)
|
||||
- `application.properties` - 添加日志级别环境变量支持(+6 行)
|
||||
|
||||
**前端变更**:
|
||||
- `Layout.tsx` - 更新 Telegram 群链接(+4 行)
|
||||
|
||||
**配置文件变更**:
|
||||
- `docker-compose.yml` - 添加日志级别环境变量(+4 行)
|
||||
- `docker-compose.prod.yml` - 添加日志级别环境变量(+4 行)
|
||||
- `deploy.sh` - 添加日志级别配置说明(+5 行)
|
||||
|
||||
**文档变更**:
|
||||
- `README.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
|
||||
- `README_EN.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
|
||||
- `RELEASE.md` - 更新 Telegram 链接(+4 行)
|
||||
- `docs/zh/smart-money-analysis.md` - 删除文档(-836 行)
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### API 变更
|
||||
|
||||
- **新增接口**:
|
||||
- `POST /api/clob/fee-rate` - 获取 Maker Rebates Program 费率(内部使用)
|
||||
- **无移除接口**
|
||||
|
||||
### 环境变量变更
|
||||
|
||||
- **新增环境变量**:
|
||||
- `LOG_LEVEL_ROOT` - Root 日志级别(默认:INFO)
|
||||
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
|
||||
|
||||
### 合约调用修复
|
||||
|
||||
- **修复的函数调用**:
|
||||
- 从 `conditions(bytes32)` 改为 `getOutcomeSlotCount(bytes32)` 和 `payoutDenominator(bytes32)`
|
||||
- 使用正确的 ConditionalTokens 合约函数签名
|
||||
- 参考:https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045#code
|
||||
|
||||
## 📝 升级说明
|
||||
|
||||
### 数据库升级
|
||||
|
||||
- **无需数据库迁移**:本次更新不涉及数据库结构变更
|
||||
|
||||
### 配置更新
|
||||
|
||||
- **可选配置**:新增日志级别环境变量,如不配置将使用默认值
|
||||
- `LOG_LEVEL_ROOT=INFO`(默认)
|
||||
- `LOG_LEVEL_APP=DEBUG`(默认)
|
||||
|
||||
### Docker 部署
|
||||
|
||||
- **推荐更新**:使用 Docker Hub 镜像部署的用户,建议更新到最新版本
|
||||
```bash
|
||||
docker pull wrbug/polyhermes:latest
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
- **Twitter**:@polyhermes
|
||||
- **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
**发布日期**:2026-01-07
|
||||
|
||||
---
|
||||
|
||||
# v1.1.5
|
||||
|
||||
## 🔧 功能优化与改进
|
||||
|
||||
### 前端优化
|
||||
|
||||
#### 优化 InputNumber 输入框格式化
|
||||
- 优化数值输入框的格式化逻辑,修正正则表达式以正确处理整数显示
|
||||
- 更新所有相关 InputNumber 组件的 formatter 函数,确保显示准确性
|
||||
- 影响的组件:CopyTradingAdd、CopyTradingEdit、EditModal、TemplateAdd、TemplateEdit、TemplateList
|
||||
- 影响范围:跟单配置、模板配置中的所有数值输入框
|
||||
|
||||
#### 优化数字显示格式
|
||||
- 添加 `formatNumber` 工具函数,自动去除小数尾随零(如 100.00 → 100)
|
||||
- 统一所有数值输入框的显示格式,提升用户体验
|
||||
|
||||
### 后端优化
|
||||
|
||||
#### 优化按比例跟单金额计算逻辑
|
||||
- 优化按比例计算的订单金额处理,使用向上取整确保满足最小限制要求
|
||||
- 对订单金额进行向上取整处理(保留 2 位小数精度)
|
||||
- 自动调整订单数量以满足最小限制要求
|
||||
- 使用 `RoundingMode.CEILING` 确保金额满足最小限制
|
||||
- 影响范围:按比例跟单的订单创建逻辑
|
||||
- 技术细节:
|
||||
- 扩展 `BigDecimal.div()` 扩展函数,支持指定精度和舍入模式
|
||||
- 在 `CopyOrderTrackingService` 中优化金额计算和验证逻辑
|
||||
|
||||
#### 增强 copyRatio 精度支持
|
||||
- 将 copyRatio 字段精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
|
||||
- 支持更精确的跟单比例设置(最小 0.01%,最大 10000%)
|
||||
- 影响的实体:CopyTrading、CopyTradingTemplate
|
||||
- 数据库迁移:新增 V18 迁移脚本,自动升级数据库字段精度
|
||||
|
||||
## 🔧 功能优化
|
||||
|
||||
### 移除刷新代理钱包接口
|
||||
- **移除接口**:
|
||||
- `POST /api/accounts/refresh-proxy` - 刷新单个账户的代理地址
|
||||
- `POST /api/accounts/refresh-all-proxies` - 刷新所有账户的代理地址
|
||||
- **原因**:代理地址应在账户导入时自动计算,无需手动刷新
|
||||
- **影响范围**:AccountController、AccountService
|
||||
- **向后兼容性**:这些接口已不再使用,移除不影响现有功能
|
||||
|
||||
### 前端跟单比例配置优化
|
||||
- **最小比例**:从 10% 降低到 0.01%,支持更灵活的跟单比例设置
|
||||
- **最大比例**:增加到 10000%,满足大比例跟单需求
|
||||
- **显示格式**:比例模式显示为百分比(如 "100%" 而不是 "1x")
|
||||
- **输入验证**:增强输入验证,确保比例在合理范围内
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:3 个提交
|
||||
- **文件变更**:15 个文件
|
||||
- **代码变更**:+575 行 / -194 行(净增加 381 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `AccountController.kt` - 移除刷新代理钱包接口(-59 行)
|
||||
- `AccountService.kt` - 移除刷新代理钱包方法(-79 行)
|
||||
- `CopyTrading.kt` - 增加 copyRatio 精度
|
||||
- `CopyTradingTemplate.kt` - 增加 copyRatio 精度
|
||||
- `CopyOrderTrackingService.kt` - 优化按比例跟单金额计算逻辑(+45 行)
|
||||
- `MathExt.kt` - 扩展 div 函数支持精度和舍入模式(+20 行)
|
||||
- `V18__increase_copy_ratio_precision.sql` - 数据库迁移脚本(+14 行)
|
||||
|
||||
**前端变更**:
|
||||
- `CopyTradingAdd.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `CopyTradingEdit.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `CopyTradingList.tsx` - 优化比例显示格式
|
||||
- `CopyTradingOrders/EditModal.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `TemplateAdd.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `TemplateEdit.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `TemplateList.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `utils/index.ts` - 添加 formatNumber 工具函数(+31 行)
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### 数据库变更
|
||||
- **迁移脚本**:`V18__increase_copy_ratio_precision.sql`
|
||||
- **变更内容**:
|
||||
- `copy_trading.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
|
||||
- `copy_trading_templates.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
|
||||
- **自动执行**:升级时会自动执行迁移脚本
|
||||
|
||||
### API 变更
|
||||
- **移除接口**:
|
||||
- `POST /api/accounts/refresh-proxy`
|
||||
- `POST /api/accounts/refresh-all-proxies`
|
||||
- **无新增接口**
|
||||
|
||||
### 前端变更
|
||||
- **工具函数**:新增 `formatNumber()` 函数,用于格式化数字显示
|
||||
- **组件更新**:所有数值输入框统一使用新的 formatter 函数
|
||||
- **显示优化**:跟单模式的比例显示为百分比格式
|
||||
|
||||
## 📝 升级说明
|
||||
|
||||
### 数据库升级
|
||||
本次版本包含数据库迁移脚本,升级时会自动执行:
|
||||
- 自动增加 `copy_ratio` 字段的精度
|
||||
- 现有数据不受影响,精度升级是向后兼容的
|
||||
|
||||
### 配置变更
|
||||
无需额外配置变更。
|
||||
|
||||
### 兼容性
|
||||
- **向后兼容**:所有变更都是向后兼容的
|
||||
- **API 兼容**:移除的接口不影响现有功能(这些接口已不再使用)
|
||||
- **数据兼容**:数据库字段精度升级不会影响现有数据
|
||||
|
||||
## 🎯 主要改进
|
||||
|
||||
1. **优化输入框格式化**:优化数值输入框的显示逻辑
|
||||
2. **优化跟单金额计算**:确保按比例跟单的金额满足最小限制要求
|
||||
3. **提升精度支持**:支持更精确的跟单比例设置(0.01% - 10000%)
|
||||
4. **代码清理**:移除不再使用的刷新代理钱包接口
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Tag](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.5)
|
||||
- [变更日志](https://github.com/WrBug/PolyHermes/compare/v1.1.4...v1.1.5)
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有贡献者和测试用户的反馈与支持!
|
||||
|
||||
---
|
||||
|
||||
# v1.1.2
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 🐛 修复内存泄漏问题
|
||||
- 修复 Retrofit/OkHttpClient 实例重复创建导致的内存泄漏问题
|
||||
- 为不需要认证的 API 创建共享的 OkHttpClient 实例(Gamma API、Data API、GitHub API 等)
|
||||
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
|
||||
- RPC API 按 RPC URL 缓存,Builder Relayer API 按 relayerUrl 缓存
|
||||
- 添加 `@PreDestroy` 方法清理缓存,确保资源正确释放
|
||||
- **效果**:内存占用从运行几小时后从 400MB 涨到 1GB+ 变为保持稳定,大幅减少内存占用
|
||||
|
||||
### 📊 市场价格服务优化
|
||||
- 移除降级查询逻辑,仅保留链上 RPC 查询和 CLOB 订单簿查询
|
||||
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
|
||||
- 如果所有数据源都失败,抛出明确的异常信息
|
||||
- 价格截位到 4 位小数(向下截断,不四舍五入)
|
||||
- 简化代码逻辑,提高查询效率和准确性
|
||||
|
||||
### 🔧 代码架构优化
|
||||
- 统一 Gson 使用,改为依赖注入方式
|
||||
- 在 `GsonConfig` 中统一配置 Gson Bean(lenient 模式)
|
||||
- 所有 Service 类通过构造函数注入 Gson 实例
|
||||
- 移除所有 `GsonConverterFactory.create()` 无参调用,统一使用注入的 Gson
|
||||
- 提高代码一致性和可维护性
|
||||
|
||||
### 🗑️ 功能清理
|
||||
- 移除下单失败存储数据库的功能
|
||||
- 删除 `FailedTrade` 实体类和 `FailedTradeRepository`
|
||||
- 从 `CopyOrderTrackingService` 中移除失败交易存储逻辑
|
||||
- 创建 Flyway migration V16 删除 `failed_trade` 表
|
||||
- 下单失败时仅记录日志,不再存储到数据库,简化数据模型
|
||||
|
||||
### 🚀 部署优化
|
||||
- 自动使用当前分支名作为 Docker 版本号
|
||||
- 分支名中的 `/` 自动替换为 `-`(Docker tag 不支持 `/)
|
||||
- `docker-compose.yml` 启用 build args,从环境变量读取版本号
|
||||
- 前端页面将显示当前分支名作为版本号
|
||||
- 如果没有 Git 仓库或获取失败,使用默认值 `dev`
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复 Flyway Migration 问题
|
||||
- 恢复 V1 migration 文件,避免 checksum 不匹配
|
||||
- 保持 `V1__init_database.sql` 的原有内容不变
|
||||
- `failed_trade` 表的删除通过 V16 migration 处理
|
||||
- 确保已有数据库的 migration checksum 保持一致
|
||||
|
||||
### 修复前端编译错误
|
||||
- 修复 `PositionList.tsx` 中引用不存在的 `bestBid` 属性导致的编译错误
|
||||
- 使用 `currentPrice` 替代 `bestBid`,确保前端代码可以正常编译
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 新增智能资金分析文档(`docs/zh/smart-money-analysis.md`)
|
||||
- 详细说明智能资金分析功能的使用方法和策略
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
- 优化 `RetrofitFactory`,实现客户端实例缓存和复用
|
||||
- 优化 `CopyOrderTrackingService`,移除失败交易相关逻辑
|
||||
- 优化 `OrderStatusUpdateService`,增强订单状态更新功能
|
||||
- 优化 `TelegramNotificationService`,改进通知逻辑
|
||||
- 优化 `PositionCheckService`,简化代码结构
|
||||
- 优化 `PolymarketClobService`,改进 API 调用逻辑
|
||||
|
||||
## 📦 数据库变更
|
||||
|
||||
- 删除 `failed_trade` 表(Migration: V16)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.2
|
||||
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.1...v1.1.2
|
||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||
|
||||
## 📊 统计信息
|
||||
|
||||
- **文件变更**: 29 个文件
|
||||
- **代码变更**: +1597 行 / -678 行
|
||||
- **主要提交**: 8 个提交
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
||||
|
||||
### ✅ 官方 Docker Hub 镜像
|
||||
|
||||
**官方镜像地址**:`wrbug/polyhermes`
|
||||
|
||||
```bash
|
||||
# ✅ 正确:使用官方镜像
|
||||
docker pull wrbug/polyhermes:v1.1.2
|
||||
|
||||
# ❌ 错误:不要使用其他来源的镜像
|
||||
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
||||
```
|
||||
|
||||
### 🔗 官方渠道
|
||||
|
||||
请通过以下**唯一官方渠道**获取 PolyHermes:
|
||||
|
||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
* **Twitter**:@polyhermes
|
||||
* **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
||||
|
||||
---
|
||||
|
||||
# v1.1.1
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 🔗 链上 WebSocket 监听优化
|
||||
- 创建 `UnifiedOnChainWsService` 统一管理 WebSocket 连接,所有服务共享同一个连接
|
||||
- 创建 `OnChainWsUtils` 工具类,提取公共的链上 WebSocket 相关功能
|
||||
- 创建 `AccountOnChainMonitorService` 监听账户链上卖出和赎回事件
|
||||
- 优化 `OnChainWsService`,复用公共代码,减少代码重复
|
||||
- 支持通过链上 WebSocket 实时监听账户的卖出和赎回交易,自动更新订单状态
|
||||
|
||||
### 📊 市场状态查询优化
|
||||
- 优化市场结算状态查询,优先使用链上查询 `ConditionalTokens.getCondition`
|
||||
- 如果链上查询失败,自动降级到 Gamma API 查询
|
||||
- 提供更实时和准确的市场结算结果
|
||||
|
||||
### 🔕 自动订单通知优化
|
||||
- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_ 前缀)不再发送 Telegram 通知
|
||||
- 优化 `OrderStatusUpdateService`,跳过自动生成订单的通知处理
|
||||
- 减少不必要的通知,提升用户体验
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复移动端 API 健康页面缺少数据显示
|
||||
- 移动端添加 URL 地址显示
|
||||
- 移动端添加状态文本显示(正常/异常/未配置)
|
||||
- 移动端添加消息/状态信息显示
|
||||
- 移动端和桌面端显示信息保持一致
|
||||
|
||||
## 🔧 功能优化
|
||||
|
||||
### 优化 Telegram 推送消息格式
|
||||
- 添加价格和数量截位处理:
|
||||
* 价格保留最多4位小数(截断,不四舍五入)
|
||||
* 数量保留最多2位小数(截断,不四舍五入)
|
||||
- 优化账户信息显示格式:
|
||||
* 有账户名和钱包地址时显示:账户名(0x123...123)
|
||||
* 只有账户名时显示账户名
|
||||
* 只有钱包地址时显示脱敏后的地址
|
||||
* 都没有时显示未知账户
|
||||
|
||||
### 配置优化
|
||||
- 移除 `polygon.rpc.url` 配置,使用 RpcNodeService 统一管理 RPC 节点
|
||||
- 删除无用的 `position.push.polling-interval` 和 `position.push.heartbeat-timeout` 配置项
|
||||
- 修正日志配置中的包名(polyhermes -> polymarketbot)
|
||||
- 更新 `ApiHealthCheckService` 直接使用 `RpcNodeService.getHttpUrl()`
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 统一发布说明文件,使用 RELEASE.md 替代版本化文件(RELEASE_v1.0.1.md、RELEASE_v1.1.0.md)
|
||||
- 更新所有部署文档,移除 POLYGON_RPC_URL 相关说明
|
||||
- 更新所有 Docker Compose 配置文件,移除 POLYGON_RPC_URL 环境变量
|
||||
- 更新所有部署脚本,移除 POLYGON_RPC_URL 环境变量定义
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
- 重构链上 WebSocket 服务,提取公共代码到 `OnChainWsUtils`
|
||||
- 创建统一的 WebSocket 连接管理服务 `UnifiedOnChainWsService`
|
||||
- 添加链上查询市场结算结果的功能(`BlockchainService.getCondition`)
|
||||
- 添加 ABI 编码/解码工具方法(`EthereumUtils.decodeConditionResult`)
|
||||
- 优化代码结构,减少代码重复,提高可维护性
|
||||
|
||||
---
|
||||
|
||||
# v1.1.0
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 🔗 链上 WebSocket 实时监听
|
||||
- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易
|
||||
- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件
|
||||
- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个
|
||||
- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接
|
||||
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
|
||||
- 跟单配置生效/失效时及时更新 WebSocket 订阅
|
||||
- 使用 Gson 替换所有 JSON 解析,提高解析稳定性
|
||||
- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复
|
||||
|
||||
### 📊 RPC 节点管理
|
||||
- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点
|
||||
- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略
|
||||
- 前端添加启用/禁用开关,支持实时切换节点状态
|
||||
- 健康检查只检查启用的节点,提高检查效率
|
||||
- 节点选择时自动过滤禁用的节点
|
||||
|
||||
### 💰 卖出订单价格轮询更新
|
||||
- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新
|
||||
- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次:
|
||||
- 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
|
||||
- 清理已删除账户的订单记录
|
||||
- 支持加权平均价格计算,处理部分成交的订单
|
||||
- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新
|
||||
- 下单完成后不再立即查询价格,直接保存,等待定时任务更新
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复跟单卖出订单的 API 凭证解密问题
|
||||
- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致
|
||||
- 确保卖出订单能够正常使用 API 凭证进行认证
|
||||
|
||||
### 修复 SELL 订单精度问题
|
||||
- 修复 SELL 订单的 `makerAmount` 和 `takerAmount` 精度问题:
|
||||
- `makerAmount` (shares) 最多 2 位小数(符合 API 要求)
|
||||
- `takerAmount` (USDC) 最多 4 位小数(符合 API 要求)
|
||||
- 确保订单能够正常提交到 Polymarket API
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 添加 Docker 版本更新说明(中英文)
|
||||
- 添加链上 WebSocket 监听策略文档
|
||||
- 添加跟单逻辑总结文档
|
||||
- 更新部署文档,包含详细的版本更新步骤
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性
|
||||
- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构
|
||||
- 优化 WebSocket 连接管理,减少不必要的连接
|
||||
- 添加线程安全机制,使用 Kotlin Coroutines Mutex
|
||||
- 启用 Spring 定时任务功能(`@EnableScheduling`)
|
||||
|
||||
## 📦 数据库变更
|
||||
|
||||
- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.1
|
||||
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.0...v1.1.1
|
||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||
|
||||
## 📊 统计信息
|
||||
|
||||
- **文件变更**: 32 个文件
|
||||
- **代码变更**: +1872 行 / -1503 行
|
||||
- **主要提交**: 7 个提交
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
||||
|
||||
### ✅ 官方 Docker Hub 镜像
|
||||
|
||||
**官方镜像地址**:`wrbug/polyhermes`
|
||||
|
||||
```bash
|
||||
# ✅ 正确:使用官方镜像
|
||||
docker pull wrbug/polyhermes:v1.1.1
|
||||
|
||||
# ❌ 错误:不要使用其他来源的镜像
|
||||
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
||||
```
|
||||
|
||||
### 🔗 官方渠道
|
||||
|
||||
请通过以下**唯一官方渠道**获取 PolyHermes:
|
||||
|
||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
* **Twitter**:@polyhermes
|
||||
* **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
## 版本信息
|
||||
- **版本号**: v1.1.15
|
||||
- **发布日期**: 2026-01-19
|
||||
- **基础版本**: v1.1.14
|
||||
|
||||
## 改动摘要
|
||||
本次版本优化了订单详情处理逻辑,提升了系统稳定性和开发体验。
|
||||
|
||||
## 主要改动
|
||||
|
||||
### 🐛 Bug 修复
|
||||
|
||||
#### 1. 优化订单详情为 null 时的处理逻辑
|
||||
- **提交**: 7385eff
|
||||
- **文件**: OrderStatusUpdateService.kt
|
||||
- **问题**:
|
||||
- 订单详情为 null 且已发送通知超过 60 秒时,订单被直接删除
|
||||
- 导致已经正确处理并发送 TG 通知的订单被意外删除
|
||||
- **修复**:
|
||||
- 当订单详情为 null 且 notificationSent = true 超过 60 秒时,将订单状态改为 fully_matched
|
||||
- fully_matched 状态的订单会被自动过滤,不再查询详情
|
||||
- 避免已处理的订单被误删除
|
||||
|
||||
### 🧹 日志清理
|
||||
|
||||
#### 2. 清理 MarketPollingService 中多余的 debug 日志
|
||||
- **提交**: d768da7, 07b4d65
|
||||
- **文件**: MarketPollingService.kt
|
||||
- **改进**:
|
||||
- 删除多余的 debug 日志输出
|
||||
- 减少冗余日志,提升日志可读性
|
||||
- 优化性能(减少日志 I/O)
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
#### 3. 添加订单详情查询工具脚本
|
||||
- **提交**: b658270
|
||||
- **新增文件**:
|
||||
- scripts/get-order-detail.js - 订单详情查询脚本
|
||||
- scripts/package.json - 依赖配置文件
|
||||
- **功能**:
|
||||
- 快速查询 Polymarket 订单详情
|
||||
- 支持自动创建 API Key
|
||||
- 完善的错误处理和参数验证
|
||||
- 详细的订单信息输出
|
||||
|
||||
## 文件变更统计
|
||||
- **修改文件数**: 3
|
||||
- **新增文件数**: 2
|
||||
- **新增行数**: 192
|
||||
- **删除行数**: 6
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 订单状态管理优化
|
||||
- 使用 fully_matched 状态标记已处理订单
|
||||
- 通过数据库查询条件自动过滤,无需额外缓存
|
||||
- 保持数据一致性和可追溯性
|
||||
|
||||
### 工具脚本特性
|
||||
- 基于 Polymarket CLOB Client v5.2.1
|
||||
- 支持 derive/create API Key 自动处理
|
||||
- 完整的参数验证和错误提示
|
||||
- 友好的命令行交互体验
|
||||
|
||||
## 升级建议
|
||||
- 无需特殊操作,直接部署即可
|
||||
- 建议验证订单处理逻辑是否正常工作
|
||||
- 可以使用新增的工具脚本进行调试
|
||||
|
||||
## 完整提交列表
|
||||
- 7385eff - 优化订单详情为null时的处理逻辑
|
||||
- d768da7 - 清理 MarketPollingService 中多余的 debug 日志
|
||||
- b658270 - 添加订单详情查询脚本
|
||||
- 07b4d65 - 清理 MarketPollingService 调试日志
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export PROXY_PORT=8888
|
||||
- 代理配置错误
|
||||
|
||||
**排查步骤**:
|
||||
1. 检查 `polymarket.rtds.ws-url` 配置是否正确
|
||||
1. 检查 Polymarket RTDS WebSocket URL(现在使用代码常量 `PolymarketConstants.RTDS_WS_URL`)
|
||||
2. 检查网络连接
|
||||
3. 查看详细错误日志
|
||||
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# 清理 Flyway V29 失败记录的脚本
|
||||
|
||||
echo "=== 清理 Flyway V29 失败记录 ==="
|
||||
echo ""
|
||||
echo "请确保 MySQL 正在运行,然后输入数据库密码"
|
||||
echo ""
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="3306"
|
||||
DB_NAME="polymarket_bot"
|
||||
DB_USER="root"
|
||||
|
||||
# 检查 MySQL 命令是否可用
|
||||
if ! command -v mysql &> /dev/null; then
|
||||
echo "❌ 错误: 未找到 mysql 命令"
|
||||
echo ""
|
||||
echo "请使用数据库客户端(如 Navicat、DataGrip 等)执行以下 SQL:"
|
||||
echo ""
|
||||
echo "-- 1. 查看 Flyway 历史记录"
|
||||
echo "SELECT version, description, installed_on, success "
|
||||
echo "FROM flyway_schema_history "
|
||||
echo "WHERE version >= 28"
|
||||
echo "ORDER BY installed_rank;"
|
||||
echo ""
|
||||
echo "-- 2. 删除 V29 的失败记录"
|
||||
echo "DELETE FROM flyway_schema_history WHERE version = '29';"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 执行清理
|
||||
echo "正在连接数据库..."
|
||||
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p "$DB_NAME" << 'EOF'
|
||||
-- 查看当前状态
|
||||
SELECT '=== 当前 Flyway 历史记录 ===' as '';
|
||||
SELECT version, description, installed_on, success
|
||||
FROM flyway_schema_history
|
||||
WHERE version >= 28
|
||||
ORDER BY installed_rank;
|
||||
|
||||
-- 删除 V29 失败记录
|
||||
SELECT '=== 删除 V29 记录 ===' as '';
|
||||
DELETE FROM flyway_schema_history WHERE version = '29';
|
||||
|
||||
-- 确认删除结果
|
||||
SELECT CONCAT('已删除 ', ROW_COUNT(), ' 条记录') as result;
|
||||
|
||||
-- 再次查看状态
|
||||
SELECT '=== 清理后的 Flyway 历史记录 ===' as '';
|
||||
SELECT version, description, installed_on, success
|
||||
FROM flyway_schema_history
|
||||
WHERE version >= 28
|
||||
ORDER BY installed_rank;
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "✅ 清理完成!现在可以重启应用了"
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,26 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* 币安现货公开 API(K 线等)
|
||||
* Base URL: https://api.binance.com
|
||||
* 文档: https://developers.binance.com/docs/binance-spot-api-docs/rest-api
|
||||
*/
|
||||
interface BinanceApi {
|
||||
|
||||
/**
|
||||
* K 线数据
|
||||
* 返回每根 K 线: [openTime, open, high, low, close, volume, closeTime, ...]
|
||||
*/
|
||||
@GET("/api/v3/klines")
|
||||
fun getKlines(
|
||||
@Query("symbol") symbol: String,
|
||||
@Query("interval") interval: String,
|
||||
@Query("limit") limit: Int = 30,
|
||||
@Query("startTime") startTime: Long? = null,
|
||||
@Query("endTime") endTime: Long? = null
|
||||
): Call<List<List<Any>>>
|
||||
}
|
||||
@@ -41,6 +41,17 @@ interface BuilderRelayerApi {
|
||||
@Query("address") address: String,
|
||||
@Query("type") type: String
|
||||
): Response<NoncePayload>
|
||||
|
||||
/**
|
||||
* 获取 Relay Payload(PROXY 类型执行时使用)
|
||||
* GET /relay-payload?address={address}&type=PROXY
|
||||
* 参考: builder-relayer-client endpoints GET_RELAY_PAYLOAD
|
||||
*/
|
||||
@GET("/relay-payload")
|
||||
suspend fun getRelayPayload(
|
||||
@Query("address") address: String,
|
||||
@Query("type") type: String
|
||||
): Response<RelayPayload>
|
||||
|
||||
/**
|
||||
* 获取交易状态
|
||||
@@ -81,7 +92,7 @@ interface BuilderRelayerApi {
|
||||
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
|
||||
|
||||
@SerializedName("nonce")
|
||||
val nonce: String, // Safe nonce(字符串)
|
||||
val nonce: String? = null, // Safe nonce(SAFE 必填,SAFE-CREATE 不传)
|
||||
|
||||
@SerializedName("signature")
|
||||
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
|
||||
@@ -96,6 +107,7 @@ interface BuilderRelayerApi {
|
||||
/**
|
||||
* 签名参数
|
||||
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
|
||||
* Safe 使用 operation/safeTxnGas/baseGas 等,PROXY 使用 relayHub/relay/relayerFee 等
|
||||
*/
|
||||
data class SignatureParams(
|
||||
@SerializedName("gasPrice")
|
||||
@@ -114,7 +126,29 @@ interface BuilderRelayerApi {
|
||||
val gasToken: String? = null,
|
||||
|
||||
@SerializedName("refundReceiver")
|
||||
val refundReceiver: String? = null
|
||||
val refundReceiver: String? = null,
|
||||
|
||||
@SerializedName("relayerFee")
|
||||
val relayerFee: String? = null,
|
||||
|
||||
@SerializedName("gasLimit")
|
||||
val gasLimit: String? = null,
|
||||
|
||||
@SerializedName("relayHub")
|
||||
val relayHub: String? = null,
|
||||
|
||||
@SerializedName("relay")
|
||||
val relay: String? = null,
|
||||
|
||||
/** SAFE-CREATE 签名参数 */
|
||||
@SerializedName("paymentToken")
|
||||
val paymentToken: String? = null,
|
||||
|
||||
@SerializedName("payment")
|
||||
val payment: String? = null,
|
||||
|
||||
@SerializedName("paymentReceiver")
|
||||
val paymentReceiver: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -142,6 +176,17 @@ interface BuilderRelayerApi {
|
||||
@SerializedName("nonce")
|
||||
val nonce: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Relay Payload(PROXY 执行时获取 relay 地址与 nonce)
|
||||
* 参考: builder-relayer-client types RelayPayload
|
||||
*/
|
||||
data class RelayPayload(
|
||||
@SerializedName("address")
|
||||
val address: String,
|
||||
@SerializedName("nonce")
|
||||
val nonce: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Relayer 交易详情
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
@@ -164,10 +165,10 @@ interface PolymarketClobApi {
|
||||
|
||||
/**
|
||||
* 获取服务器时间
|
||||
* 端点: /time
|
||||
* 端点: /time 返回纯数字(Unix 时间戳),非 JSON
|
||||
*/
|
||||
@GET("/time")
|
||||
suspend fun getServerTime(): Response<ServerTimeResponse>
|
||||
suspend fun getServerTime(): Response<ResponseBody>
|
||||
}
|
||||
|
||||
// 请求和响应数据类
|
||||
@@ -211,6 +212,7 @@ data class NewOrderResponse(
|
||||
val success: Boolean, // boolean indicating if server-side error
|
||||
@SerializedName("errorMsg")
|
||||
val errorMsg: String? = null, // error message in case of unsuccessful placement
|
||||
val error: String? = null, // error message (alternative field, e.g. "Trading restricted in your region...")
|
||||
@SerializedName("orderID")
|
||||
val orderId: String? = null, // id of order(API 返回字段名为 orderID)
|
||||
@SerializedName("transactionsHashes")
|
||||
@@ -221,7 +223,17 @@ data class NewOrderResponse(
|
||||
val takingAmount: String? = null, // taking amount
|
||||
@SerializedName("makingAmount")
|
||||
val makingAmount: String? = null // making amount
|
||||
)
|
||||
) {
|
||||
/**
|
||||
* 获取错误信息的便捷方法
|
||||
* 优先返回 errorMsg,其次返回 error,最后返回默认消息
|
||||
*/
|
||||
fun getErrorMessage(): String {
|
||||
return errorMsg?.takeIf { it.isNotBlank() }
|
||||
?: error?.takeIf { it.isNotBlank() }
|
||||
?: "创建订单失败"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧的订单请求格式(已废弃,保留用于兼容)
|
||||
@@ -334,7 +346,8 @@ data class TradeResponse(
|
||||
val timestamp: String, // ISO 8601 格式字符串或时间戳
|
||||
val user: String?,
|
||||
val outcomeIndex: Int? = null, // 结果索引(0=YES, 1=NO)
|
||||
val outcome: String? = null // 结果名称(如 "Up", "Down")
|
||||
val outcome: String? = null, // 结果名称(如 "Up", "Down")
|
||||
val tokenId: String? = null // CLOB tokenId(链上解析时从 ERC1155 取得,与 Gamma clobTokenIds 一致,用于下单)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -363,13 +376,6 @@ data class ApiKeyResponse(
|
||||
val passphrase: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 服务器时间响应
|
||||
*/
|
||||
data class ServerTimeResponse(
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 费率响应
|
||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
@@ -26,23 +27,56 @@ interface PolymarketGammaApi {
|
||||
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
|
||||
@Query("include_tag") includeTag: Boolean? = null
|
||||
): Response<List<MarketResponse>>
|
||||
|
||||
/**
|
||||
* 根据 slug 获取事件(用于 5/15 分钟加密市场)
|
||||
* GET /events/slug/{slug},如 btc-updown-5m-1771007400
|
||||
* 返回事件含 markets(conditionId、endDate、clobTokenIds 等)
|
||||
*/
|
||||
@GET("/events/slug/{slug}")
|
||||
suspend fun getEventBySlug(@Path("slug") slug: String): Response<GammaEventBySlugResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* Gamma 按 slug 返回的事件结构
|
||||
*/
|
||||
data class GammaEventBySlugResponse(
|
||||
val id: String? = null,
|
||||
val slug: String? = null,
|
||||
val title: String? = null,
|
||||
val startDate: String? = null,
|
||||
val endDate: String? = null,
|
||||
val markets: List<GammaEventMarketItem>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 事件下的市场项(5/15 分钟市场为二元,通常两个 outcome)
|
||||
*/
|
||||
data class GammaEventMarketItem(
|
||||
val conditionId: String? = null,
|
||||
val question: String? = null,
|
||||
val endDate: String? = null,
|
||||
val startDate: String? = null,
|
||||
val clobTokenIds: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 事件响应(从 MarketResponse.events 解析)
|
||||
* Gamma API Event 含 negRisk,用于判断是否使用 Neg Risk Exchange 签约
|
||||
*/
|
||||
data class EventResponse(
|
||||
val id: String? = null,
|
||||
val ticker: String? = null,
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val slug: String? = null,
|
||||
val title: String? = null,
|
||||
val category: String? = null,
|
||||
val active: Boolean? = null,
|
||||
val closed: Boolean? = null,
|
||||
val archived: Boolean? = null,
|
||||
val startDate: String? = null,
|
||||
val endDate: String? = null,
|
||||
val createdAt: String? = null
|
||||
val createdAt: String? = null,
|
||||
val negRisk: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -74,6 +108,8 @@ data class MarketResponse(
|
||||
val events: List<EventResponse>? = null, // 事件列表(从 events[0] 获取 slug)
|
||||
// 以下字段可能存在于响应中,但不在标准文档中
|
||||
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
|
||||
val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本)
|
||||
val clob_token_ids: String? = null, // 下划线格式(兼容不同 API 版本)
|
||||
val negRisk: Boolean? = null, // 事件级 neg risk(部分 API 直接返回在 market)
|
||||
val negRiskOther: Boolean? = null // Market 级 neg risk 标记
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
|
||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
|
||||
import jakarta.annotation.PostConstruct
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
/**
|
||||
* 加密价差策略监控服务配置
|
||||
* 处理 WebSocketSubscriptionService 和 CryptoTailMonitorService 之间的循环依赖
|
||||
*/
|
||||
@Configuration
|
||||
class MonitorServiceConfig(
|
||||
private val webSocketSubscriptionService: WebSocketSubscriptionService,
|
||||
private val cryptoTailMonitorService: CryptoTailMonitorService
|
||||
) {
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
// 在所有 Bean 初始化后设置引用
|
||||
webSocketSubscriptionService.setCryptoTailMonitorService(cryptoTailMonitorService)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import retrofit2.Retrofit
|
||||
@@ -23,9 +23,6 @@ class RetrofitConfig(
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private lateinit var clobBaseUrl: String
|
||||
|
||||
/**
|
||||
* 创建 CLOB API 客户端
|
||||
* 用于跟单系统的订单操作和交易查询
|
||||
@@ -38,7 +35,7 @@ class RetrofitConfig(
|
||||
val okHttpClient = createClient().build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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/"
|
||||
|
||||
/**
|
||||
* Polymarket Safe 代理工厂合约地址(Polygon 主网)
|
||||
* 用于 Safe 类型账户的代理部署(SAFE-CREATE)
|
||||
*/
|
||||
const val SAFE_PROXY_FACTORY_ADDRESS = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
|
||||
|
||||
/** SafeCreate 用 EIP-712 domain name,与 builder-relayer-client 一致 */
|
||||
const val SAFE_FACTORY_EIP712_NAME = "Polymarket Contract Proxy Factory"
|
||||
}
|
||||
|
||||
+130
-7
@@ -23,6 +23,50 @@ class AccountController(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AccountController::class.java)
|
||||
|
||||
/**
|
||||
* 检查代理地址选项(用于导入前选择代理类型)
|
||||
*/
|
||||
@PostMapping("/check-proxy-options")
|
||||
fun checkProxyOptions(@RequestBody request: CheckProxyOptionsRequest): ResponseEntity<ApiResponse<CheckProxyOptionsResponse>> {
|
||||
return try {
|
||||
if (request.walletAddress.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_WALLET_ADDRESS_EMPTY, messageSource = messageSource))
|
||||
}
|
||||
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "必须提供私钥或助记词", messageSource))
|
||||
}
|
||||
|
||||
val result = runBlocking { accountService.checkProxyOptions(request) }
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("检查代理地址选项失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
else -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查代理地址选项异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过私钥导入账户
|
||||
*/
|
||||
@@ -45,14 +89,17 @@ class AccountController(
|
||||
onFailure = { e ->
|
||||
logger.error("导入账户失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
|
||||
} else {
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
@@ -157,6 +204,82 @@ class AccountController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户设置状态(代理部署、交易启用、代币批准)
|
||||
*/
|
||||
@PostMapping("/check-setup-status")
|
||||
fun checkSetupStatus(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountSetupStatusDto>> {
|
||||
return try {
|
||||
if (request.accountId == null || request.accountId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
val result = runBlocking { accountService.checkAccountSetupStatus(request.accountId) }
|
||||
result.fold(
|
||||
onSuccess = { status ->
|
||||
ResponseEntity.ok(ApiResponse.success(status))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("检查账户设置状态失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
else -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查账户设置状态异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行设置步骤(步骤1 返回跳转 URL,步骤2/3 由后端执行)
|
||||
*/
|
||||
@PostMapping("/execute-setup-step")
|
||||
fun executeSetupStep(@RequestBody request: ExecuteSetupStepRequest): ResponseEntity<ApiResponse<ExecuteSetupStepResponse>> {
|
||||
return try {
|
||||
if (request.accountId == null || request.accountId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
val step = request.step ?: 0
|
||||
if (step !in 1..3) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "步骤必须为 1、2 或 3", messageSource))
|
||||
}
|
||||
val result = runBlocking { accountService.executeSetupStep(request.accountId, step) }
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("执行设置步骤失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource)
|
||||
)
|
||||
else -> ResponseEntity.ok(
|
||||
ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("执行设置步骤异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户详情
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.controller.auth
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.repository.UserRepository
|
||||
import com.wrbug.polymarketbot.service.auth.AuthService
|
||||
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
@@ -18,7 +19,8 @@ import org.springframework.web.bind.annotation.*
|
||||
class AuthController(
|
||||
private val authService: AuthService,
|
||||
private val messageSource: MessageSource,
|
||||
private val webSocketTicketService: WebSocketTicketService
|
||||
private val webSocketTicketService: WebSocketTicketService,
|
||||
private val userRepository: UserRepository
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AuthController::class.java)
|
||||
@@ -184,5 +186,32 @@ class AuthController(
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证当前用户权限
|
||||
* 用于动态更新服务验证管理员权限
|
||||
* 管理员权限判断:是否为默认账户(isDefault == true)
|
||||
*/
|
||||
@GetMapping("/verify")
|
||||
fun verify(httpRequest: HttpServletRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
// 从请求属性中获取用户名(由 JWT 拦截器设置)
|
||||
val username = httpRequest.getAttribute("username") as? String
|
||||
if (username == null) {
|
||||
return ResponseEntity.status(401).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource))
|
||||
}
|
||||
|
||||
// 检查是否为默认账户(管理员)
|
||||
val user = userRepository.findByUsername(username)
|
||||
if (user == null || !user.isDefault) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "需要管理员权限", messageSource))
|
||||
}
|
||||
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
} catch (e: Exception) {
|
||||
logger.error("验证权限异常: ${e.message}", e)
|
||||
ResponseEntity.status(500).body(ApiResponse.error(ErrorCode.SERVER_ERROR, "验证失败", messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
package com.wrbug.polymarketbot.controller.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.backtest.BacktestService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* 回测管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/backtest")
|
||||
class BacktestController(
|
||||
private val backtestService: BacktestService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BacktestController::class.java)
|
||||
|
||||
/**
|
||||
* 创建回测任务
|
||||
*/
|
||||
@PostMapping("/tasks")
|
||||
fun createBacktestTask(@RequestBody request: BacktestCreateRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
|
||||
return try {
|
||||
logger.info("创建回测任务: taskName=${request.taskName}, leaderId=${request.leaderId}")
|
||||
|
||||
val result = runBlocking {
|
||||
backtestService.createBacktestTask(request)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { dto ->
|
||||
logger.info("回测任务创建成功: taskId=${dto.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(dto))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("创建回测任务失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.PARAM_ERROR
|
||||
else -> ErrorCode.SERVER_BACKTEST_CREATE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建回测任务异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_CREATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测任务列表
|
||||
*/
|
||||
@PostMapping("/tasks/list")
|
||||
fun getBacktestTaskList(@RequestBody request: BacktestListRequest): ResponseEntity<ApiResponse<BacktestListResponse>> {
|
||||
return try {
|
||||
val result = backtestService.getBacktestTaskList(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("查询回测任务列表成功: total=${response.total}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询回测任务列表失败", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测任务列表异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测任务详情
|
||||
*/
|
||||
@PostMapping("/tasks/detail")
|
||||
fun getBacktestTaskDetail(@RequestBody request: BacktestDetailRequest): ResponseEntity<ApiResponse<BacktestDetailResponse>> {
|
||||
return try {
|
||||
val result = backtestService.getBacktestTaskDetail(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("查询回测任务详情成功: taskId=${request.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询回测任务详情失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
else -> ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测任务详情异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测交易记录
|
||||
*/
|
||||
@PostMapping("/tasks/trades")
|
||||
fun getBacktestTrades(@RequestBody request: BacktestTradeListRequest): ResponseEntity<ApiResponse<BacktestTradeListResponse>> {
|
||||
return try {
|
||||
val result = backtestService.getBacktestTrades(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("查询回测交易记录成功: taskId=${request.taskId}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询回测交易记录失败", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测交易记录异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除回测任务
|
||||
*/
|
||||
@PostMapping("/tasks/delete")
|
||||
fun deleteBacktestTask(@RequestBody request: BacktestDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
logger.info("删除回测任务: taskId=${request.id}")
|
||||
|
||||
val result = backtestService.deleteBacktestTask(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("回测任务删除成功: taskId=${request.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("删除回测任务失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||
else -> ErrorCode.SERVER_BACKTEST_DELETE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除回测任务异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DELETE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止回测任务
|
||||
*/
|
||||
@PostMapping("/tasks/stop")
|
||||
fun stopBacktestTask(@RequestBody request: BacktestStopRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
logger.info("停止回测任务: taskId=${request.id}")
|
||||
|
||||
val result = backtestService.stopBacktestTask(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("回测任务停止成功: taskId=${request.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("停止回测任务失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||
else -> ErrorCode.SERVER_BACKTEST_STOP_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("停止回测任务异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_STOP_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试回测任务
|
||||
*/
|
||||
@PostMapping("/tasks/retry")
|
||||
fun retryBacktestTask(@RequestBody request: BacktestRetryRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
logger.info("重试回测任务: taskId=${request.id}")
|
||||
|
||||
val result = backtestService.retryBacktestTask(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("回测任务重试成功: taskId=${request.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("重试回测任务失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||
else -> ErrorCode.SERVER_BACKTEST_RETRY_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("重试回测任务异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RETRY_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前配置重新测试:基于已完成的回测任务创建相同配置的新任务(仅支持已完成任务)
|
||||
*/
|
||||
@PostMapping("/tasks/rerun")
|
||||
fun rerunBacktestTask(@RequestBody request: BacktestRerunRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
|
||||
return try {
|
||||
logger.info("按配置重新测试: sourceTaskId=${request.id}, newTaskName=${request.taskName}")
|
||||
|
||||
val result = backtestService.rerunBacktestTask(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { dto ->
|
||||
logger.info("重新测试任务创建成功: newTaskId=${dto.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(dto))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("按配置重新测试失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
is IllegalStateException -> ErrorCode.BACKTEST_TASK_NOT_COMPLETED
|
||||
else -> ErrorCode.SERVER_BACKTEST_RERUN_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("按配置重新测试异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RERUN_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -140,7 +140,7 @@ class LeaderController(
|
||||
if (request.leaderId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
|
||||
|
||||
val result = leaderService.getLeaderDetail(request.leaderId)
|
||||
result.fold(
|
||||
onSuccess = { leader ->
|
||||
@@ -159,6 +159,36 @@ class LeaderController(
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询被跟单者余额
|
||||
*/
|
||||
@PostMapping("/balance")
|
||||
fun getLeaderBalance(@RequestBody request: LeaderBalanceRequest): ResponseEntity<ApiResponse<LeaderBalanceResponse>> {
|
||||
return try {
|
||||
if (request.leaderId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
|
||||
val result = leaderService.getLeaderBalance(request.leaderId)
|
||||
result.fold(
|
||||
onSuccess = { balance ->
|
||||
ResponseEntity.ok(ApiResponse.success(balance))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询 Leader 余额失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
|
||||
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
|
||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 Leader 余额异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
package com.wrbug.polymarketbot.controller.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApiResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyCreateRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyDeleteRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyDto
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyListRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyListResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveResponse
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
|
||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
|
||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyExecutionService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/crypto-tail-strategy")
|
||||
class CryptoTailStrategyController(
|
||||
private val cryptoTailStrategyService: CryptoTailStrategyService,
|
||||
private val cryptoTailMonitorService: CryptoTailMonitorService,
|
||||
private val cryptoTailStrategyExecutionService: CryptoTailStrategyExecutionService,
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailStrategyController::class.java)
|
||||
|
||||
@PostMapping("/list")
|
||||
fun list(@RequestBody request: CryptoTailStrategyListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyListResponse>> {
|
||||
return try {
|
||||
val result = cryptoTailStrategyService.list(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询加密价差策略列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询加密价差策略列表异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
fun create(@RequestBody request: CryptoTailStrategyCreateRequest): ResponseEntity<ApiResponse<CryptoTailStrategyDto>> {
|
||||
return try {
|
||||
val result = cryptoTailStrategyService.create(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("创建加密价差策略失败: ${e.message}", e)
|
||||
val code = when (e.message) {
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID
|
||||
else -> ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建加密价差策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/update")
|
||||
fun update(@RequestBody request: CryptoTailStrategyUpdateRequest): ResponseEntity<ApiResponse<CryptoTailStrategyDto>> {
|
||||
return try {
|
||||
if (request.strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.update(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("更新加密价差策略失败: ${e.message}", e)
|
||||
val code = when (e.message) {
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID
|
||||
else -> ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新加密价差策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
fun delete(@RequestBody request: CryptoTailStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val strategyId = request.strategyId
|
||||
if (strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.delete(strategyId)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
|
||||
onFailure = { e ->
|
||||
logger.error("删除加密价差策略失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除加密价差策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/pnl-curve")
|
||||
fun getPnlCurve(@RequestBody request: CryptoTailPnlCurveRequest): ResponseEntity<ApiResponse<CryptoTailPnlCurveResponse>> {
|
||||
return try {
|
||||
if (request.strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.getPnlCurve(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询收益曲线失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询收益曲线异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/triggers")
|
||||
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
|
||||
return try {
|
||||
if (request.strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.getTriggerRecords(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询触发记录失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询触发记录异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/market-options")
|
||||
fun getMarketOptions(): ResponseEntity<ApiResponse<List<CryptoTailMarketOptionDto>>> {
|
||||
return try {
|
||||
val options = listOf(
|
||||
CryptoTailMarketOptionDto(slug = "btc-updown-5m", title = "Bitcoin Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "btc-updown-15m", title = "Bitcoin Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "eth-updown-5m", title = "Ethereum Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "eth-updown-15m", title = "Ethereum Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "sol-updown-5m", title = "Solana Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "sol-updown-15m", title = "Solana Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "xrp-updown-5m", title = "XRP Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "xrp-updown-15m", title = "XRP Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null)
|
||||
)
|
||||
ResponseEntity.ok(ApiResponse.success(options))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取市场选项异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动最小价差预览:按「当前周期」计算一次并返回,仅用于前端展示参考。
|
||||
* 实际触发时按每个周期在需要时计算,不依赖此接口。
|
||||
*/
|
||||
@PostMapping("/auto-min-spread")
|
||||
fun getAutoMinSpread(@RequestBody request: java.util.Map<String, Any>): ResponseEntity<ApiResponse<CryptoTailAutoMinSpreadResponse>> {
|
||||
return try {
|
||||
val intervalSeconds = (request["intervalSeconds"] as? Number)?.toInt() ?: 300
|
||||
if (intervalSeconds != 300 && intervalSeconds != 900) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource))
|
||||
}
|
||||
val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong()
|
||||
?: ((System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds)
|
||||
// 默认使用 BTC 市场(向后兼容)
|
||||
val marketSlugPrefix = (request["marketSlugPrefix"] as? String) ?: "btc-updown"
|
||||
val pair = binanceKlineAutoSpreadService.computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix)
|
||||
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "fetch_failed", messageSource))
|
||||
val body = CryptoTailAutoMinSpreadResponse(
|
||||
minSpreadUp = pair.first.toPlainString(),
|
||||
minSpreadDown = pair.second.toPlainString()
|
||||
)
|
||||
ResponseEntity.ok(ApiResponse.success(body))
|
||||
} catch (e: Exception) {
|
||||
logger.error("计算自动最小价差异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化加密价差策略监控
|
||||
* 返回策略信息、开盘价、tokenIds等初始化数据
|
||||
*/
|
||||
@PostMapping("/monitor/init")
|
||||
fun initMonitor(@RequestBody request: CryptoTailMonitorInitRequest): ResponseEntity<ApiResponse<CryptoTailMonitorInitResponse>> {
|
||||
return try {
|
||||
if (request.strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailMonitorService.initMonitor(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("初始化加密价差策略监控失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("初始化加密价差策略监控异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动下单
|
||||
* 用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单
|
||||
*/
|
||||
@PostMapping("/manual-order")
|
||||
fun manualOrder(@RequestBody request: CryptoTailManualOrderRequest): ResponseEntity<ApiResponse<CryptoTailManualOrderResponse>> {
|
||||
return runBlocking {
|
||||
try {
|
||||
if (request.strategyId <= 0) {
|
||||
return@runBlocking ResponseEntity.ok(
|
||||
ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource)
|
||||
)
|
||||
}
|
||||
val result = cryptoTailStrategyExecutionService.manualOrder(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("手动下单失败: ${e.message}", e)
|
||||
val code = when (e.message) {
|
||||
"策略不存在" -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
|
||||
"当前周期已下单" -> ErrorCode.PARAM_ERROR
|
||||
"价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR
|
||||
"数量不能少于 1" -> ErrorCode.PARAM_ERROR
|
||||
"总金额不能少于 1 USDC" -> ErrorCode.PARAM_ERROR
|
||||
"总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR
|
||||
else -> ErrorCode.SERVER_ERROR
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("手动下单异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.system
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.system.NotificationConfigService
|
||||
import com.wrbug.polymarketbot.service.system.NotificationTemplateService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
@@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.*
|
||||
class NotificationController(
|
||||
private val notificationConfigService: NotificationConfigService,
|
||||
private val telegramNotificationService: TelegramNotificationService,
|
||||
private val notificationTemplateService: NotificationTemplateService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
@@ -335,6 +337,155 @@ class NotificationController(
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 模板相关 API ====================
|
||||
|
||||
/**
|
||||
* 获取所有模板类型
|
||||
*/
|
||||
@PostMapping("/templates/types")
|
||||
fun getTemplateTypes(): ResponseEntity<ApiResponse<List<TemplateTypeInfoDto>>> {
|
||||
return try {
|
||||
val types = notificationTemplateService.getTemplateTypes()
|
||||
ResponseEntity.ok(ApiResponse.success(types))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取模板类型失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有模板
|
||||
*/
|
||||
@PostMapping("/templates/list")
|
||||
fun getTemplates(): ResponseEntity<ApiResponse<List<NotificationTemplateDto>>> {
|
||||
return try {
|
||||
val templates = notificationTemplateService.getAllTemplates()
|
||||
ResponseEntity.ok(ApiResponse.success(templates))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取模板列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个模板
|
||||
*/
|
||||
@PostMapping("/templates/detail")
|
||||
fun getTemplateDetail(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
|
||||
return try {
|
||||
if (request.templateType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||
}
|
||||
|
||||
val template = notificationTemplateService.getTemplate(request.templateType)
|
||||
if (template == null) {
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
|
||||
} else {
|
||||
ResponseEntity.ok(ApiResponse.success(template))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取模板详情失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板可用变量
|
||||
*/
|
||||
@PostMapping("/templates/variables")
|
||||
fun getTemplateVariables(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<TemplateVariablesResponse>> {
|
||||
return try {
|
||||
if (request.templateType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||
}
|
||||
|
||||
val variables = notificationTemplateService.getTemplateVariables(request.templateType)
|
||||
if (variables == null) {
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
|
||||
} else {
|
||||
ResponseEntity.ok(ApiResponse.success(variables))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取模板变量失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模板
|
||||
*/
|
||||
@PostMapping("/templates/update")
|
||||
fun updateTemplate(@RequestBody request: UpdateTemplateRequestWithId): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
|
||||
return try {
|
||||
if (request.templateType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||
}
|
||||
if (request.templateContent.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("模板内容不能为空"))
|
||||
}
|
||||
|
||||
val template = notificationTemplateService.updateTemplate(request.templateType, request.templateContent)
|
||||
ResponseEntity.ok(ApiResponse.success(template))
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新模板失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置模板为默认
|
||||
*/
|
||||
@PostMapping("/templates/reset")
|
||||
fun resetTemplate(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
|
||||
return try {
|
||||
if (request.templateType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||
}
|
||||
|
||||
val template = notificationTemplateService.resetTemplate(request.templateType)
|
||||
if (template == null) {
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
|
||||
} else {
|
||||
ResponseEntity.ok(ApiResponse.success(template))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("重置模板失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送模板测试消息
|
||||
*/
|
||||
@PostMapping("/templates/test")
|
||||
fun testTemplate(@RequestBody request: TestTemplateRequest): ResponseEntity<ApiResponse<Boolean>> {
|
||||
return try {
|
||||
if (request.templateType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||
}
|
||||
|
||||
val success = runBlocking {
|
||||
notificationTemplateService.sendTestMessage(request.templateType, request.templateContent)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
ResponseEntity.ok(ApiResponse.success(true))
|
||||
} else {
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.NOTIFICATION_TEST_FAILED,
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("发送模板测试消息失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.NOTIFICATION_TEST_FAILED,
|
||||
customMsg = "发送测试消息失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,3 +535,18 @@ data class NotificationConfigDeleteRequest(
|
||||
val id: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 模板详情请求
|
||||
*/
|
||||
data class TemplateDetailRequest(
|
||||
val templateType: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新模板请求(带类型)
|
||||
*/
|
||||
data class UpdateTemplateRequestWithId(
|
||||
val templateType: String,
|
||||
val templateContent: String
|
||||
)
|
||||
|
||||
|
||||
@@ -11,6 +11,37 @@ data class AccountImportRequest(
|
||||
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
)
|
||||
|
||||
/**
|
||||
* 检查代理地址选项请求
|
||||
*/
|
||||
data class CheckProxyOptionsRequest(
|
||||
val walletAddress: String, // EOA 地址(必需)
|
||||
val privateKey: String? = null, // 私钥(加密,私钥导入时提供)
|
||||
val mnemonic: String? = null // 助记词(加密,助记词导入时提供)
|
||||
)
|
||||
|
||||
/**
|
||||
* 代理地址选项信息
|
||||
*/
|
||||
data class ProxyOptionDto(
|
||||
val walletType: String, // "magic" 或 "safe"
|
||||
val proxyAddress: String, // 代理地址
|
||||
val descriptionKey: String, // 说明文案的多语言 key(如 "accountImport.proxyOption.magic.description")
|
||||
val availableBalance: String, // 可用余额
|
||||
val positionBalance: String, // 仓位余额
|
||||
val totalBalance: String, // 总余额
|
||||
val positionCount: Int, // 持仓数量
|
||||
val hasAssets: Boolean, // 是否有资产(余额>0 或持仓>0)
|
||||
val error: String? = null // 获取失败时的错误信息(可选)
|
||||
)
|
||||
|
||||
/**
|
||||
* 检查代理地址选项响应
|
||||
*/
|
||||
data class CheckProxyOptionsResponse(
|
||||
val options: List<ProxyOptionDto> // 代理地址选项列表(私钥导入返回2个,助记词返回1个)
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户更新请求
|
||||
*/
|
||||
@@ -37,9 +68,9 @@ data class SystemConfigDto(
|
||||
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
||||
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
||||
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
|
||||
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示)
|
||||
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示)
|
||||
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示)
|
||||
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(完整,用于前端展示)
|
||||
val builderSecretDisplay: String? = null, // Builder Secret 显示值(完整,用于前端展示)
|
||||
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(完整,用于前端展示)
|
||||
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
|
||||
)
|
||||
|
||||
@@ -93,6 +124,16 @@ data class AccountListResponse(
|
||||
val total: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 钱包余额响应(通用类,用于 Account 和 Leader)
|
||||
*/
|
||||
data class WalletBalanceResponse(
|
||||
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
|
||||
val positionBalance: String, // 仓位余额(持仓总价值)
|
||||
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
|
||||
val positions: List<PositionDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户余额响应
|
||||
*/
|
||||
@@ -108,6 +149,7 @@ data class AccountBalanceResponse(
|
||||
*/
|
||||
data class PositionDto(
|
||||
val marketId: String,
|
||||
val title: String?, // 市场名称
|
||||
val side: String, // YES 或 NO
|
||||
val quantity: String,
|
||||
val avgPrice: String,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 账户设置状态检查结果
|
||||
*/
|
||||
data class AccountSetupStatusDto(
|
||||
/**
|
||||
* 步骤1:代理钱包是否已部署
|
||||
*/
|
||||
val proxyDeployed: Boolean,
|
||||
|
||||
/**
|
||||
* 步骤2:交易是否已启用(API Key 是否已配置)
|
||||
*/
|
||||
val tradingEnabled: Boolean,
|
||||
|
||||
/**
|
||||
* 步骤3:代币是否已批准
|
||||
*/
|
||||
val tokensApproved: Boolean,
|
||||
|
||||
/**
|
||||
* 代币批准详情(各合约的授权额度)
|
||||
* Key: 合约名称(CTF_CONTRACT, CTF_EXCHANGE, NEG_RISK_EXCHANGE, NEG_RISK_ADAPTER)
|
||||
* Value: 授权额度(USDC,6位小数)
|
||||
*/
|
||||
val approvalDetails: Map<String, String>? = null,
|
||||
|
||||
/**
|
||||
* 检查错误信息(如果有)
|
||||
*/
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 执行设置步骤请求
|
||||
*/
|
||||
data class ExecuteSetupStepRequest(
|
||||
/** 账户 ID */
|
||||
val accountId: Long? = null,
|
||||
/** 步骤:1=部署代理, 2=启用交易, 3=批准代币 */
|
||||
val step: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 执行设置步骤响应
|
||||
*/
|
||||
data class ExecuteSetupStepResponse(
|
||||
/** 是否由后端执行成功(步骤1 仅返回跳转链接,为 false) */
|
||||
val success: Boolean = false,
|
||||
/** 需跳转时由后端提供的 URL(步骤1 使用) */
|
||||
val redirectUrl: String? = null,
|
||||
/** 链上交易哈希(步骤3 批准代币成功时返回) */
|
||||
val transactionHash: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户导入响应(扩展,包含设置状态)
|
||||
*/
|
||||
data class AccountImportResponse(
|
||||
val account: AccountDto,
|
||||
val setupStatus: AccountSetupStatusDto? = null // 设置状态检查结果(可选)
|
||||
)
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 回测任务创建请求
|
||||
*/
|
||||
data class BacktestCreateRequest(
|
||||
val taskName: String, // 回测任务名称
|
||||
val leaderId: Long, // Leader ID
|
||||
val initialBalance: String, // 初始资金
|
||||
val backtestDays: Int, // 回测天数 (1-30)
|
||||
// 跟单配置(与 CopyTrading 一致,但不包含 max_position_count)
|
||||
val copyMode: String? = null, // "RATIO" 或 "FIXED"
|
||||
val copyRatio: String? = null, // 仅在 copyMode="RATIO" 时生效
|
||||
val fixedAmount: String? = null, // 仅在 copyMode="FIXED" 时生效
|
||||
val maxOrderSize: String? = null,
|
||||
val minOrderSize: String? = null,
|
||||
val maxDailyLoss: String? = null,
|
||||
val maxDailyOrders: Int? = null,
|
||||
val supportSell: Boolean? = null,
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务列表请求
|
||||
*/
|
||||
data class BacktestListRequest(
|
||||
val leaderId: Long? = null, // Leader ID(可选)
|
||||
val status: String? = null, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||
val sortBy: String? = null, // profitAmount / profitRate / createdAt
|
||||
val sortOrder: String? = null, // asc / desc
|
||||
val page: Int = 1, // 页码,从1开始
|
||||
val size: Int = 20 // 每页数量
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务详情请求
|
||||
*/
|
||||
data class BacktestDetailRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测交易记录请求
|
||||
*/
|
||||
data class BacktestTradeListRequest(
|
||||
val taskId: Long, // 回测任务ID
|
||||
val page: Int = 1, // 页码,从1开始
|
||||
val size: Int = 20 // 每页数量
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测进度查询请求
|
||||
*/
|
||||
data class BacktestProgressRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务停止请求
|
||||
*/
|
||||
data class BacktestStopRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务删除请求
|
||||
*/
|
||||
data class BacktestDeleteRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务重试请求
|
||||
*/
|
||||
data class BacktestRetryRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 按当前配置重新测试请求(仅支持已完成任务)
|
||||
*/
|
||||
data class BacktestRerunRequest(
|
||||
val id: Long, // 源回测任务ID
|
||||
val taskName: String? = null // 新任务名称,为空时使用「原名称 (副本)」
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务列表响应
|
||||
*/
|
||||
data class BacktestListResponse(
|
||||
val list: List<BacktestTaskDto>,
|
||||
val total: Long,
|
||||
val page: Int,
|
||||
val size: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务详情响应
|
||||
*/
|
||||
data class BacktestDetailResponse(
|
||||
val task: BacktestTaskDto,
|
||||
val config: BacktestConfigDto,
|
||||
val statistics: BacktestStatisticsDto
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测交易记录列表响应
|
||||
*/
|
||||
data class BacktestTradeListResponse(
|
||||
val list: List<BacktestTradeDto>,
|
||||
val total: Long,
|
||||
val page: Int,
|
||||
val size: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测进度响应
|
||||
*/
|
||||
data class BacktestProgressResponse(
|
||||
val progress: Int, // 执行进度 (0-100)
|
||||
val currentBalance: String, // 当前余额
|
||||
val totalTrades: Int, // 总交易笔数
|
||||
val status: String // 任务状态
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务 DTO
|
||||
*/
|
||||
data class BacktestTaskDto(
|
||||
val id: Long,
|
||||
val taskName: String,
|
||||
val leaderId: Long,
|
||||
val leaderName: String?,
|
||||
val leaderAddress: String?,
|
||||
val initialBalance: String,
|
||||
val finalBalance: String?,
|
||||
val profitAmount: String?,
|
||||
val profitRate: String?,
|
||||
val backtestDays: Int,
|
||||
val startTime: Long,
|
||||
val endTime: Long?,
|
||||
val status: String, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||
val progress: Int,
|
||||
val totalTrades: Int,
|
||||
val createdAt: Long,
|
||||
val executionStartedAt: Long?,
|
||||
val executionFinishedAt: Long?
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测配置 DTO
|
||||
*/
|
||||
data class BacktestConfigDto(
|
||||
val copyMode: String,
|
||||
val copyRatio: String,
|
||||
val fixedAmount: String?,
|
||||
val maxOrderSize: String,
|
||||
val minOrderSize: String,
|
||||
val maxDailyLoss: String,
|
||||
val maxDailyOrders: Int,
|
||||
val supportSell: Boolean,
|
||||
val keywordFilterMode: String?,
|
||||
val keywords: List<String>?,
|
||||
val maxPositionValue: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? // 最高价格(可选),NULL表示不限制最高价
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测统计信息 DTO
|
||||
*/
|
||||
data class BacktestStatisticsDto(
|
||||
val totalTrades: Int, // 总交易笔数
|
||||
val buyTrades: Int, // 买入笔数
|
||||
val sellTrades: Int, // 卖出笔数
|
||||
val winTrades: Int, // 盈利交易笔数
|
||||
val lossTrades: Int, // 亏损交易笔数
|
||||
val winRate: String, // 胜率(%)
|
||||
val maxProfit: String, // 最大单笔盈利
|
||||
val maxLoss: String, // 最大单笔亏损
|
||||
val maxDrawdown: String, // 最大回撤
|
||||
val avgHoldingTime: Long? // 平均持仓时间(毫秒)
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测交易记录 DTO
|
||||
*/
|
||||
data class BacktestTradeDto(
|
||||
val id: Long,
|
||||
val tradeTime: Long,
|
||||
val marketId: String,
|
||||
val marketTitle: String?,
|
||||
val side: String, // BUY/SELL/SETTLEMENT
|
||||
val outcome: String,
|
||||
val outcomeIndex: Int?,
|
||||
val quantity: String,
|
||||
val price: String,
|
||||
val amount: String,
|
||||
val fee: String,
|
||||
val profitLoss: String?,
|
||||
val balanceAfter: String,
|
||||
val leaderTradeId: String?
|
||||
)
|
||||
|
||||
@@ -36,7 +36,6 @@ data class CopyTradingCreateRequest(
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 关键字过滤配置
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||
@@ -75,7 +74,6 @@ data class CopyTradingUpdateRequest(
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 关键字过滤配置
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||
@@ -151,7 +149,6 @@ data class CopyTradingDto(
|
||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 关键字过滤配置
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 加密价差策略手动下单请求
|
||||
*/
|
||||
data class CryptoTailManualOrderRequest(
|
||||
/** 策略ID */
|
||||
val strategyId: Long = 0L,
|
||||
/** 当前周期开始时间 (Unix 秒) */
|
||||
val periodStartUnix: Long = 0L,
|
||||
/** 下单方向: UP or DOWN */
|
||||
val direction: String = "UP",
|
||||
/** 下单价格 */
|
||||
val price: String = "0",
|
||||
/** 下单数量 */
|
||||
val size: String = "1",
|
||||
/** 市场标题(用于记录) */
|
||||
val marketTitle: String = "",
|
||||
/** Token IDs */
|
||||
val tokenIds: List<String> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 加密价差策略手动下单响应
|
||||
*/
|
||||
data class CryptoTailManualOrderResponse(
|
||||
/** 是否成功 */
|
||||
val success: Boolean = false,
|
||||
/** 订单ID */
|
||||
val orderId: String? = null,
|
||||
/** 提示消息 */
|
||||
val message: String = "",
|
||||
/** 下单详情 */
|
||||
val orderDetails: ManualOrderDetails? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 手动下单详情
|
||||
*/
|
||||
data class ManualOrderDetails(
|
||||
/** 策略ID */
|
||||
val strategyId: Long = 0L,
|
||||
/** 方向 */
|
||||
val direction: String = "",
|
||||
/** 下单价格 */
|
||||
val price: String = "",
|
||||
/** 下单数量 */
|
||||
val size: String = "",
|
||||
/** 总金额 */
|
||||
val totalAmount: String = ""
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 加密价差策略监控初始化请求
|
||||
*/
|
||||
data class CryptoTailMonitorInitRequest(
|
||||
/** 策略ID */
|
||||
val strategyId: Long = 0L,
|
||||
/** 指定周期开始时间 (Unix 秒),不传则用服务器当前周期 */
|
||||
val periodStartUnix: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 加密价差策略监控初始化响应
|
||||
*/
|
||||
data class CryptoTailMonitorInitResponse(
|
||||
/** 策略ID */
|
||||
val strategyId: Long = 0L,
|
||||
/** 策略名称 */
|
||||
val name: String = "",
|
||||
/** 账户ID */
|
||||
val accountId: Long = 0L,
|
||||
/** 账户名称 */
|
||||
val accountName: String = "",
|
||||
/** 市场 slug 前缀 */
|
||||
val marketSlugPrefix: String = "",
|
||||
/** 市场标题 */
|
||||
val marketTitle: String = "",
|
||||
/** 周期秒数 (300=5m, 900=15m) */
|
||||
val intervalSeconds: Int = 300,
|
||||
/** 当前周期开始时间 (Unix 秒) */
|
||||
val periodStartUnix: Long = 0L,
|
||||
/** 时间窗口开始秒数 */
|
||||
val windowStartSeconds: Int = 0,
|
||||
/** 时间窗口结束秒数 */
|
||||
val windowEndSeconds: Int = 0,
|
||||
/** 最低价格 */
|
||||
val minPrice: String = "0",
|
||||
/** 最高价格 */
|
||||
val maxPrice: String = "1",
|
||||
/** 最小价差模式: NONE, FIXED, AUTO */
|
||||
val minSpreadMode: String = "NONE",
|
||||
/** 价差方向: MIN(显示周期内最小价差), MAX(显示周期内最大价差) */
|
||||
val spreadDirection: String = "MIN",
|
||||
/** 最小价差数值 (FIXED 时有值) */
|
||||
val minSpreadValue: String? = null,
|
||||
/** 自动计算的最小价差 (Up方向) */
|
||||
val autoMinSpreadUp: String? = null,
|
||||
/** 自动计算的最小价差 (Down方向) */
|
||||
val autoMinSpreadDown: String? = null,
|
||||
/** BTC 开盘价 USDC(来自币安 K 线 open) */
|
||||
val openPriceBtc: String? = null,
|
||||
/** Up tokenId */
|
||||
val tokenIdUp: String? = null,
|
||||
/** Down tokenId */
|
||||
val tokenIdDown: String? = null,
|
||||
/** 当前时间 (毫秒时间戳) */
|
||||
val currentTimestamp: Long = System.currentTimeMillis(),
|
||||
/** 是否启用 */
|
||||
val enabled: Boolean = true,
|
||||
/** 投入金额模式: FIXED or RATIO */
|
||||
val amountMode: String? = null,
|
||||
/** 投入金额数值 */
|
||||
val amountValue: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 加密价差策略监控实时推送数据
|
||||
*/
|
||||
data class CryptoTailMonitorPushData(
|
||||
/** 策略ID */
|
||||
val strategyId: Long = 0L,
|
||||
/** 推送时间 (毫秒时间戳) */
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
/** 当前周期开始时间 (Unix 秒) */
|
||||
val periodStartUnix: Long = 0L,
|
||||
/** 当前周期市场标题(周期切换时更新) */
|
||||
val marketTitle: String = "",
|
||||
/** 当前价格 (Up方向,来自订单簿) */
|
||||
val currentPriceUp: String? = null,
|
||||
/** 当前价格 (Down方向,来自订单簿) */
|
||||
val currentPriceDown: String? = null,
|
||||
/** 当前价差 (Up方向: 1 - currentPriceUp) */
|
||||
val spreadUp: String? = null,
|
||||
/** 当前价差 (Down方向: currentPriceUp) */
|
||||
val spreadDown: String? = null,
|
||||
/** 最小价差线 (Up方向) */
|
||||
val minSpreadLineUp: String? = null,
|
||||
/** 最小价差线 (Down方向,USDC 价差) */
|
||||
val minSpreadLineDown: String? = null,
|
||||
/** BTC 开盘价 USDC(币安 K 线 open) */
|
||||
val openPriceBtc: String? = null,
|
||||
/** BTC 最新价 USDC(币安 K 线 close,当前周期实时) */
|
||||
val currentPriceBtc: String? = null,
|
||||
/** BTC 价差 USDC(currentPriceBtc - openPriceBtc) */
|
||||
val spreadBtc: String? = null,
|
||||
/** 周期剩余秒数 */
|
||||
val remainingSeconds: Int = 0,
|
||||
/** 是否在时间窗口内 */
|
||||
val inTimeWindow: Boolean = false,
|
||||
/** 是否在价格区间内 (Up方向) */
|
||||
val inPriceRangeUp: Boolean = false,
|
||||
/** 是否在价格区间内 (Down方向) */
|
||||
val inPriceRangeDown: Boolean = false,
|
||||
/** 是否已触发 */
|
||||
val triggered: Boolean = false,
|
||||
/** 触发方向: UP, DOWN, null */
|
||||
val triggerDirection: String? = null,
|
||||
/** 周期是否已结束 */
|
||||
val periodEnded: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 加密价差策略创建请求
|
||||
* 金额与价格使用 String,后端转为 BigDecimal
|
||||
*/
|
||||
data class CryptoTailStrategyCreateRequest(
|
||||
val accountId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val marketSlugPrefix: String = "",
|
||||
val intervalSeconds: Int = 300,
|
||||
val windowStartSeconds: Int = 0,
|
||||
val windowEndSeconds: Int = 0,
|
||||
val minPrice: String = "0",
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
/** 价差模式: NONE, FIXED, AUTO */
|
||||
val spreadMode: String = "NONE",
|
||||
/** 价差数值 */
|
||||
val spreadValue: String? = null,
|
||||
/** 价差方向: MIN=最小价差, MAX=最大价差 */
|
||||
val spreadDirection: String = "MIN",
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
/**
|
||||
* 加密价差策略更新请求
|
||||
*/
|
||||
data class CryptoTailStrategyUpdateRequest(
|
||||
val strategyId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val windowStartSeconds: Int? = null,
|
||||
val windowEndSeconds: Int? = null,
|
||||
val minPrice: String? = null,
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String? = null,
|
||||
val amountValue: String? = null,
|
||||
/** 价差模式: NONE, FIXED, AUTO */
|
||||
val spreadMode: String? = null,
|
||||
/** 价差数值 */
|
||||
val spreadValue: String? = null,
|
||||
/** 价差方向: MIN=最小价差, MAX=最大价差 */
|
||||
val spreadDirection: String? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 加密价差策略列表请求
|
||||
*/
|
||||
data class CryptoTailStrategyListRequest(
|
||||
val accountId: Long? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 加密价差策略 DTO(列表与详情)
|
||||
*/
|
||||
data class CryptoTailStrategyDto(
|
||||
val id: Long = 0L,
|
||||
val accountId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val marketSlugPrefix: String = "",
|
||||
val marketTitle: String? = null,
|
||||
val intervalSeconds: Int = 0,
|
||||
val windowStartSeconds: Int = 0,
|
||||
val windowEndSeconds: Int = 0,
|
||||
val minPrice: String = "0",
|
||||
val maxPrice: String = "1",
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
/** 价差模式: NONE, FIXED, AUTO */
|
||||
val spreadMode: String = "NONE",
|
||||
/** 价差数值 */
|
||||
val spreadValue: String? = null,
|
||||
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
|
||||
val spreadDirection: String = "MIN",
|
||||
val enabled: Boolean = true,
|
||||
val lastTriggerAt: Long? = null,
|
||||
/** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */
|
||||
val totalRealizedPnl: String? = null,
|
||||
/** 已结算笔数(用于胜率分母) */
|
||||
val settledCount: Long = 0L,
|
||||
/** 已结算中赢的笔数(用于胜率分子) */
|
||||
val winCount: Long = 0L,
|
||||
/** 胜率 0~1(已结算时 = winCount/settledCount,无结算为 null) */
|
||||
val winRate: String? = null,
|
||||
val createdAt: Long = 0L,
|
||||
val updatedAt: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 加密价差策略列表响应
|
||||
*/
|
||||
data class CryptoTailStrategyListResponse(
|
||||
val list: List<CryptoTailStrategyDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 加密价差策略删除请求
|
||||
*/
|
||||
data class CryptoTailStrategyDeleteRequest(
|
||||
val strategyId: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录列表请求
|
||||
* @param startDate 开始日期(当天 00:00:00.000 的时间戳毫秒),为 null 表示不限制
|
||||
* @param endDate 结束日期(当天 23:59:59.999 的时间戳毫秒),为 null 表示不限制
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerListRequest(
|
||||
val strategyId: Long = 0L,
|
||||
val page: Int = 1,
|
||||
val pageSize: Int = 20,
|
||||
val status: String? = null,
|
||||
val startDate: Long? = null,
|
||||
val endDate: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录 DTO
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerDto(
|
||||
val id: Long = 0L,
|
||||
val strategyId: Long = 0L,
|
||||
val periodStartUnix: Long = 0L,
|
||||
val marketTitle: String? = null,
|
||||
val outcomeIndex: Int = 0,
|
||||
val triggerPrice: String = "0",
|
||||
val amountUsdc: String = "0",
|
||||
val orderId: String? = null,
|
||||
val status: String = "success",
|
||||
val failReason: String? = null,
|
||||
/** 是否已结算 */
|
||||
val resolved: Boolean = false,
|
||||
/** 已实现盈亏 USDC(结算后有值) */
|
||||
val realizedPnl: String? = null,
|
||||
/** 市场赢家 outcome 索引(结算后有值) */
|
||||
val winnerOutcomeIndex: Int? = null,
|
||||
val settledAt: Long? = null,
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录分页响应
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerListResponse(
|
||||
val list: List<CryptoTailStrategyTriggerDto> = emptyList(),
|
||||
val total: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 自动价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7)
|
||||
*/
|
||||
data class CryptoTailAutoMinSpreadResponse(
|
||||
val minSpreadUp: String = "0",
|
||||
val minSpreadDown: String = "0"
|
||||
)
|
||||
|
||||
/**
|
||||
* 5/15 分钟市场项(供前端选择市场)
|
||||
*/
|
||||
data class CryptoTailMarketOptionDto(
|
||||
val slug: String = "",
|
||||
val title: String = "",
|
||||
val intervalSeconds: Int = 0,
|
||||
val periodStartUnix: Long = 0L,
|
||||
val endDate: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 收益曲线请求
|
||||
* @param strategyId 策略ID
|
||||
* @param startDate 开始时间(毫秒时间戳),null 表示不限制
|
||||
* @param endDate 结束时间(毫秒时间戳),null 表示不限制
|
||||
*/
|
||||
data class CryptoTailPnlCurveRequest(
|
||||
val strategyId: Long = 0L,
|
||||
val startDate: Long? = null,
|
||||
val endDate: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 收益曲线单点数据
|
||||
*/
|
||||
data class CryptoTailPnlCurvePoint(
|
||||
/** 时间点(毫秒时间戳,结算时间或创建时间) */
|
||||
val timestamp: Long = 0L,
|
||||
/** 累计收益 USDC */
|
||||
val cumulativePnl: String = "0",
|
||||
/** 当笔收益 USDC */
|
||||
val pointPnl: String = "0",
|
||||
/** 截至该点累计已结算笔数 */
|
||||
val settledCount: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 收益曲线响应
|
||||
*/
|
||||
data class CryptoTailPnlCurveResponse(
|
||||
val strategyId: Long = 0L,
|
||||
val strategyName: String = "",
|
||||
/** 筛选范围内总已实现收益 USDC */
|
||||
val totalRealizedPnl: String = "0",
|
||||
val settledCount: Long = 0L,
|
||||
val winCount: Long = 0L,
|
||||
val winRate: String? = null,
|
||||
/** 最大回撤 USDC(正数表示回撤幅度) */
|
||||
val maxDrawdown: String? = null,
|
||||
val curveData: List<CryptoTailPnlCurvePoint> = emptyList()
|
||||
)
|
||||
@@ -36,6 +36,13 @@ data class LeaderListRequest(
|
||||
val category: String? = null // sports 或 crypto
|
||||
)
|
||||
|
||||
/**
|
||||
* Leader 余额请求
|
||||
*/
|
||||
data class LeaderBalanceRequest(
|
||||
val leaderId: Long // LeaderID(必需)
|
||||
)
|
||||
|
||||
/**
|
||||
* Leader 信息响应
|
||||
*/
|
||||
@@ -47,6 +54,7 @@ data class LeaderDto(
|
||||
val remark: String? = null, // Leader 备注(可选)
|
||||
val website: String? = null, // Leader 网站(可选)
|
||||
val copyTradingCount: Long = 0, // 跟单关系数量
|
||||
val backtestCount: Long = 0, // 回测数量
|
||||
val totalOrders: Long? = null, // 总订单数(可选)
|
||||
val totalPnl: String? = null, // 总盈亏(可选)
|
||||
val createdAt: Long,
|
||||
@@ -61,3 +69,16 @@ data class LeaderListResponse(
|
||||
val total: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* Leader 余额响应
|
||||
*/
|
||||
data class LeaderBalanceResponse(
|
||||
val leaderId: Long,
|
||||
val leaderAddress: String,
|
||||
val leaderName: String?,
|
||||
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
|
||||
val positionBalance: String, // 仓位余额(持仓总价值)
|
||||
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
|
||||
val positions: List<PositionDto> = emptyList()
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 消息模板 DTO
|
||||
*/
|
||||
data class NotificationTemplateDto(
|
||||
val id: Long? = null,
|
||||
val templateType: String, // 模板类型
|
||||
val templateContent: String, // 模板内容
|
||||
val isDefault: Boolean = false, // 是否使用默认模板
|
||||
val createdAt: Long? = null,
|
||||
val updatedAt: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 模板变量 DTO
|
||||
*/
|
||||
data class TemplateVariableDto(
|
||||
val key: String, // 变量名,如 account_name
|
||||
val category: String, // 分类:common, order, copy_trading, redeem, error
|
||||
val sortOrder: Int = 0 // 排序顺序
|
||||
)
|
||||
|
||||
/**
|
||||
* 模板变量分类 DTO
|
||||
*/
|
||||
data class TemplateVariableCategoryDto(
|
||||
val key: String, // 分类 key
|
||||
val sortOrder: Int = 0 // 排序顺序
|
||||
)
|
||||
|
||||
/**
|
||||
* 模板变量列表响应
|
||||
*/
|
||||
data class TemplateVariablesResponse(
|
||||
val templateType: String, // 模板类型
|
||||
val categories: List<TemplateVariableCategoryDto>, // 分类列表
|
||||
val variables: List<TemplateVariableDto> // 变量列表
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新模板请求
|
||||
*/
|
||||
data class UpdateTemplateRequest(
|
||||
val templateContent: String // 模板内容
|
||||
)
|
||||
|
||||
/**
|
||||
* 测试模板请求
|
||||
*/
|
||||
data class TestTemplateRequest(
|
||||
val templateType: String, // 模板类型
|
||||
val templateContent: String? = null // 可选,如果不提供则使用已保存的模板
|
||||
)
|
||||
|
||||
/**
|
||||
* 模板类型信息
|
||||
*/
|
||||
data class TemplateTypeInfoDto(
|
||||
val type: String, // 模板类型
|
||||
val name: String, // 类型名称
|
||||
val description: String // 类型描述
|
||||
)
|
||||
@@ -51,18 +51,21 @@ data class OrderPushMessage(
|
||||
|
||||
/**
|
||||
* 订单详情(通过 API 获取)
|
||||
* @param price 订单限价(用户提交的买入/卖出价)
|
||||
* @param avgFilledPrice 实际成交价 = original_size * price / size_matched(有成交时优先用于推送展示)
|
||||
*/
|
||||
data class OrderDetailDto(
|
||||
val id: String, // 订单 ID
|
||||
val market: String, // 市场 ID (condition ID)
|
||||
val side: String, // BUY/SELL
|
||||
val price: String, // 价格
|
||||
val price: String, // 订单限价
|
||||
val size: String, // 订单大小
|
||||
val filled: String, // 已成交数量
|
||||
val status: String, // 订单状态
|
||||
val createdAt: String, // 创建时间(ISO 8601 格式)
|
||||
val marketName: String? = null, // 市场名称(通过 Data API 获取)
|
||||
val marketSlug: String? = null, // 市场 slug
|
||||
val marketIcon: String? = null // 市场图标
|
||||
val marketIcon: String? = null, // 市场图标
|
||||
val avgFilledPrice: String? = null // 实际成交价 = original_size*price/size_matched(有成交时使用)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 用户交易数据
|
||||
* 用于回测功能,从 Polymarket API 获取的用户交易历史
|
||||
*/
|
||||
data class TradeData(
|
||||
val tradeId: String, // 交易 ID
|
||||
val marketId: String, // 市场 ID
|
||||
val marketTitle: String?, // 市场标题
|
||||
val marketSlug: String?, // 市场 Slug
|
||||
val side: String, // 交易方向: BUY/SELL
|
||||
val outcome: String, // 结果: YES/NO 或 outcomeIndex
|
||||
val outcomeIndex: Int?, // 结果索引
|
||||
val price: BigDecimal, // 成交价格
|
||||
val size: BigDecimal, // 成交数量
|
||||
val amount: BigDecimal, // 成交金额
|
||||
val timestamp: Long // 交易时间戳
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is TradeData) return false
|
||||
return tradeId == other.tradeId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return tradeId.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ data class Account(
|
||||
@Column(name = "private_key", nullable = false, length = 500)
|
||||
val privateKey: String, // 私钥(AES 加密存储)
|
||||
|
||||
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
|
||||
val walletAddress: String, // 钱包地址(从私钥推导)
|
||||
@Column(name = "wallet_address", nullable = false, length = 42)
|
||||
val walletAddress: String, // 钱包地址(从私钥推导),同一 EOA 可有多个账户(不同代理类型)
|
||||
|
||||
@Column(name = "proxy_address", nullable = false, length = 42)
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
|
||||
@Column(name = "proxy_address", unique = true, nullable = false, length = 42)
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须),唯一
|
||||
|
||||
@Column(name = "api_key", length = 500)
|
||||
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
|
||||
/**
|
||||
* 回测任务实体
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "backtest_task")
|
||||
data class BacktestTask(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "task_name", nullable = false, length = 100)
|
||||
val taskName: String,
|
||||
|
||||
@Column(name = "leader_id", nullable = false)
|
||||
val leaderId: Long,
|
||||
|
||||
// 回测参数
|
||||
@Column(name = "initial_balance", nullable = false, precision = 20, scale = 8)
|
||||
val initialBalance: BigDecimal,
|
||||
|
||||
@Column(name = "final_balance", precision = 20, scale = 8)
|
||||
var finalBalance: BigDecimal? = null,
|
||||
|
||||
@Column(name = "profit_amount", precision = 20, scale = 8)
|
||||
var profitAmount: BigDecimal? = null,
|
||||
|
||||
@Column(name = "profit_rate", precision = 10, scale = 4)
|
||||
var profitRate: BigDecimal? = null, // 收益率(%)
|
||||
|
||||
@Column(name = "backtest_days", nullable = false)
|
||||
val backtestDays: Int,
|
||||
|
||||
@Column(name = "start_time", nullable = false)
|
||||
val startTime: Long, // 回测开始时间(历史时间),创建时计算;执行时以当前时间为基准用局部变量重算窗口
|
||||
|
||||
@Column(name = "end_time")
|
||||
var endTime: Long? = null, // 回测结束时间(历史时间)
|
||||
|
||||
// 跟单配置 (复制CopyTrading表结构,但不包含 max_position_count)
|
||||
@Column(name = "copy_mode", nullable = false, length = 10)
|
||||
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
|
||||
|
||||
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
|
||||
val copyRatio: BigDecimal = BigDecimal.ONE,
|
||||
|
||||
@Column(name = "fixed_amount", precision = 20, scale = 8)
|
||||
val fixedAmount: BigDecimal? = null,
|
||||
|
||||
@Column(name = "max_order_size", nullable = false, precision = 20, scale = 8)
|
||||
val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(),
|
||||
|
||||
@Column(name = "min_order_size", nullable = false, precision = 20, scale = 8)
|
||||
val minOrderSize: BigDecimal = "1".toSafeBigDecimal(),
|
||||
|
||||
@Column(name = "max_daily_loss", nullable = false, precision = 20, scale = 8)
|
||||
val maxDailyLoss: BigDecimal = "10000".toSafeBigDecimal(),
|
||||
|
||||
@Column(name = "max_daily_orders", nullable = false)
|
||||
val maxDailyOrders: Int = 100,
|
||||
|
||||
@Column(name = "support_sell", nullable = false)
|
||||
val supportSell: Boolean = true,
|
||||
|
||||
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
|
||||
val keywordFilterMode: String = "DISABLED", // DISABLED/WHITELIST/BLACKLIST
|
||||
|
||||
@Column(name = "keywords", columnDefinition = "JSON")
|
||||
val keywords: String? = null,
|
||||
|
||||
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
|
||||
@Column(name = "min_price", precision = 20, scale = 8)
|
||||
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
|
||||
@Column(name = "max_price", precision = 20, scale = 8)
|
||||
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
|
||||
// 统计字段
|
||||
@Column(name = "avg_holding_time")
|
||||
var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒)
|
||||
|
||||
@Column(name = "data_source", length = 50)
|
||||
var dataSource: String = "MIXED", // INTERNAL/API/MIXED
|
||||
|
||||
// 执行状态
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
var status: String = "PENDING", // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||
|
||||
@Column(name = "progress", nullable = false)
|
||||
var progress: Int = 0, // 执行进度(0-100)
|
||||
|
||||
@Column(name = "total_trades", nullable = false)
|
||||
var totalTrades: Int = 0,
|
||||
|
||||
@Column(name = "buy_trades", nullable = false)
|
||||
var buyTrades: Int = 0,
|
||||
|
||||
@Column(name = "sell_trades", nullable = false)
|
||||
var sellTrades: Int = 0,
|
||||
|
||||
@Column(name = "win_trades", nullable = false)
|
||||
var winTrades: Int = 0,
|
||||
|
||||
@Column(name = "loss_trades", nullable = false)
|
||||
var lossTrades: Int = 0,
|
||||
|
||||
@Column(name = "win_rate", precision = 5, scale = 2)
|
||||
var winRate: BigDecimal? = null, // 胜率(%)
|
||||
|
||||
@Column(name = "max_profit", precision = 20, scale = 8)
|
||||
var maxProfit: BigDecimal? = null, // 最大单笔盈利
|
||||
|
||||
@Column(name = "max_loss", precision = 20, scale = 8)
|
||||
var maxLoss: BigDecimal? = null, // 最大单笔亏损
|
||||
|
||||
@Column(name = "max_drawdown", precision = 20, scale = 8)
|
||||
var maxDrawdown: BigDecimal? = null, // 最大回撤
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
var errorMessage: String? = null,
|
||||
|
||||
// 时间字段
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "execution_started_at")
|
||||
var executionStartedAt: Long? = null,
|
||||
|
||||
@Column(name = "execution_finished_at")
|
||||
var executionFinishedAt: Long? = null,
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "last_processed_trade_time")
|
||||
var lastProcessedTradeTime: Long? = null,
|
||||
|
||||
@Column(name = "last_processed_trade_index")
|
||||
var lastProcessedTradeIndex: Int? = null,
|
||||
|
||||
@Column(name = "processed_trade_count")
|
||||
var processedTradeCount: Int = 0
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 回测交易记录实体
|
||||
* 用于记录回测过程中的每笔模拟交易
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "backtest_trade")
|
||||
data class BacktestTrade(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "backtest_task_id", nullable = false)
|
||||
val backtestTaskId: Long,
|
||||
|
||||
@Column(name = "trade_time", nullable = false)
|
||||
val tradeTime: Long,
|
||||
|
||||
@Column(name = "market_id", nullable = false, length = 100)
|
||||
val marketId: String,
|
||||
|
||||
@Column(name = "market_title", length = 500)
|
||||
val marketTitle: String? = null,
|
||||
|
||||
@Column(name = "side", nullable = false, length = 20)
|
||||
val side: String, // BUY/SELL/SETTLEMENT
|
||||
|
||||
@Column(name = "outcome", nullable = false, length = 50)
|
||||
val outcome: String, // YES/NO 或 outcomeIndex
|
||||
|
||||
@Column(name = "outcome_index")
|
||||
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场
|
||||
|
||||
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
|
||||
val quantity: BigDecimal,
|
||||
|
||||
@Column(name = "price", nullable = false, precision = 20, scale = 8)
|
||||
val price: BigDecimal,
|
||||
|
||||
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
|
||||
val amount: BigDecimal,
|
||||
|
||||
@Column(name = "fee", nullable = false, precision = 20, scale = 8)
|
||||
val fee: BigDecimal = BigDecimal.ZERO, // 手续费(回测不计算,默认为0)
|
||||
|
||||
@Column(name = "profit_loss", precision = 20, scale = 8)
|
||||
val profitLoss: BigDecimal? = null, // 盈亏(仅卖出时)
|
||||
|
||||
@Column(name = "balance_after", nullable = false, precision = 20, scale = 8)
|
||||
val balanceAfter: BigDecimal, // 交易后余额
|
||||
|
||||
@Column(name = "leader_trade_id", length = 100)
|
||||
val leaderTradeId: String? = null, // Leader 原始交易ID
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -58,7 +58,10 @@ data class CopyOrderTracking(
|
||||
|
||||
@Column(name = "notification_sent", nullable = 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)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
|
||||
@@ -83,9 +83,6 @@ data class CopyTrading(
|
||||
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
|
||||
@Column(name = "max_position_count")
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
|
||||
// 关键字过滤配置
|
||||
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
|
||||
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import com.wrbug.polymarketbot.enums.SpreadDirection
|
||||
import com.wrbug.polymarketbot.enums.SpreadDirectionConverter
|
||||
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||
import com.wrbug.polymarketbot.enums.SpreadModeConverter
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 加密价差策略实体
|
||||
* 5/15 分钟 Up or Down 市场,在周期内时间窗口、价格进入区间时市价买入
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "crypto_tail_strategy")
|
||||
data class CryptoTailStrategy(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "account_id", nullable = false)
|
||||
val accountId: Long = 0L,
|
||||
|
||||
@Column(name = "name", length = 255)
|
||||
val name: String? = null,
|
||||
|
||||
@Column(name = "market_slug_prefix", nullable = false, length = 64)
|
||||
val marketSlugPrefix: String = "",
|
||||
|
||||
@Column(name = "interval_seconds", nullable = false)
|
||||
val intervalSeconds: Int = 300,
|
||||
|
||||
@Column(name = "window_start_seconds", nullable = false)
|
||||
val windowStartSeconds: Int = 0,
|
||||
|
||||
@Column(name = "window_end_seconds", nullable = false)
|
||||
val windowEndSeconds: Int = 0,
|
||||
|
||||
@Column(name = "min_price", nullable = false, precision = 20, scale = 8)
|
||||
val minPrice: BigDecimal = BigDecimal.ONE,
|
||||
|
||||
@Column(name = "max_price", nullable = false, precision = 20, scale = 8)
|
||||
val maxPrice: BigDecimal = BigDecimal.ONE,
|
||||
|
||||
@Column(name = "amount_mode", nullable = false, length = 10)
|
||||
val amountMode: String = "RATIO",
|
||||
|
||||
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
|
||||
val amountValue: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
/** 价差模式: NONE=不校验, FIXED=固定值, AUTO=历史计算 */
|
||||
@Convert(converter = SpreadModeConverter::class)
|
||||
@Column(name = "spread_mode", nullable = false, columnDefinition = "TINYINT")
|
||||
val spreadMode: SpreadMode = SpreadMode.NONE,
|
||||
|
||||
/** 价差数值(FIXED 时必填;AUTO 时可存计算值) */
|
||||
@Column(name = "spread_value", precision = 20, scale = 8)
|
||||
val spreadValue: BigDecimal? = null,
|
||||
|
||||
/** 价差方向: MIN=最小价差(价差>=配置值触发),MAX=最大价差(价差<=配置值触发) */
|
||||
@Convert(converter = SpreadDirectionConverter::class)
|
||||
@Column(name = "spread_direction", nullable = false, columnDefinition = "TINYINT")
|
||||
val spreadDirection: SpreadDirection = SpreadDirection.MIN,
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
val enabled: Boolean = true,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
|
||||
/**
|
||||
* 加密价差策略触发记录
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "crypto_tail_strategy_trigger")
|
||||
data class CryptoTailStrategyTrigger(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "strategy_id", nullable = false)
|
||||
val strategyId: Long = 0L,
|
||||
|
||||
@Column(name = "period_start_unix", nullable = false)
|
||||
val periodStartUnix: Long = 0L,
|
||||
|
||||
@Column(name = "market_title", length = 500)
|
||||
val marketTitle: String? = null,
|
||||
|
||||
@Column(name = "outcome_index", nullable = false)
|
||||
val outcomeIndex: Int = 0,
|
||||
|
||||
@Column(name = "trigger_price", nullable = false, precision = 20, scale = 8)
|
||||
val triggerPrice: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "amount_usdc", nullable = false, precision = 20, scale = 8)
|
||||
val amountUsdc: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "order_id", length = 128)
|
||||
val orderId: String? = null,
|
||||
|
||||
@Column(name = "condition_id", length = 66)
|
||||
val conditionId: String? = null,
|
||||
|
||||
@Column(name = "resolved", nullable = false)
|
||||
val resolved: Boolean = false,
|
||||
|
||||
@Column(name = "winner_outcome_index")
|
||||
val winnerOutcomeIndex: Int? = null,
|
||||
|
||||
@Column(name = "realized_pnl", precision = 20, scale = 8)
|
||||
val realizedPnl: BigDecimal? = null,
|
||||
|
||||
@Column(name = "settled_at")
|
||||
val settledAt: Long? = null,
|
||||
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
val status: String = "success",
|
||||
|
||||
@Column(name = "fail_reason", length = 500)
|
||||
val failReason: String? = null,
|
||||
|
||||
@Column(name = "trigger_type", nullable = false, length = 20)
|
||||
val triggerType: String = "AUTO",
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "notification_sent", nullable = false)
|
||||
var notificationSent: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
|
||||
/**
|
||||
* 消息推送模板实体
|
||||
* 用于存储用户自定义的消息模板
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "notification_templates")
|
||||
data class NotificationTemplate(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "template_type", unique = true, nullable = false, length = 50)
|
||||
val templateType: String, // ORDER_SUCCESS, ORDER_FAILED, ORDER_FILTERED, CRYPTO_TAIL_SUCCESS, REDEEM_SUCCESS, REDEEM_NO_RETURN
|
||||
|
||||
@Column(name = "template_content", nullable = false, columnDefinition = "TEXT")
|
||||
var templateContent: String, // 模板内容,支持 {{variable}} 变量
|
||||
|
||||
@Column(name = "is_default", nullable = false)
|
||||
var isDefault: Boolean = false, // 是否使用默认模板
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -158,6 +158,13 @@ enum class ErrorCode(
|
||||
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
|
||||
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
||||
|
||||
// 加密价差策略 (4710-4729)
|
||||
CRYPTO_TAIL_STRATEGY_NOT_FOUND(4710, "加密价差策略不存在", "error.crypto_tail_strategy_not_found"),
|
||||
CRYPTO_TAIL_STRATEGY_WINDOW_INVALID(4711, "时间区间开始不能大于结束", "error.crypto_tail_strategy_window_invalid"),
|
||||
CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED(4712, "时间区间不能超过周期长度", "error.crypto_tail_strategy_window_exceed"),
|
||||
CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID(4713, "周期仅支持 300 或 900 秒", "error.crypto_tail_strategy_interval_invalid"),
|
||||
CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID(4714, "投入方式仅支持 RATIO 或 FIXED", "error.crypto_tail_strategy_amount_mode_invalid"),
|
||||
|
||||
// 统计相关 (4801-4899)
|
||||
STATISTICS_FETCH_FAILED(4801, "获取统计信息失败", "error.statistics_fetch_failed"),
|
||||
ORDER_LIST_FETCH_FAILED(4802, "查询订单列表失败", "error.order_list_fetch_failed"),
|
||||
@@ -231,7 +238,33 @@ enum class ErrorCode(
|
||||
SERVER_ORDER_TRACKING_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"),
|
||||
SERVER_ORDER_TRACKING_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_failed"),
|
||||
SERVER_ORDER_TRACKING_SELL_FAILED(5903, "处理卖出订单失败", "error.server.order_tracking_sell_failed"),
|
||||
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed");
|
||||
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed"),
|
||||
|
||||
// 回测服务错误 (4601-4699)
|
||||
BACKTEST_TASK_NOT_FOUND(4601, "回测任务不存在", "error.backtest.task_not_found"),
|
||||
BACKTEST_LEADER_NOT_FOUND(4602, "Leader不存在", "error.backtest.leader_not_found"),
|
||||
BACKTEST_DAYS_INVALID(4603, "回测天数超出限制", "error.backtest.days_invalid"),
|
||||
BACKTEST_INITIAL_BALANCE_INVALID(4604, "初始金额无效", "error.backtest.initial_balance_invalid"),
|
||||
BACKTEST_TASK_RUNNING(4605, "回测任务正在运行,无法删除", "error.backtest.task_running"),
|
||||
BACKTEST_TASK_NOT_COMPLETED(4606, "仅支持对已完成的回测任务重新测试", "error.backtest.task_not_completed"),
|
||||
SERVER_BACKTEST_CREATE_FAILED(5603, "创建回测任务失败", "error.server.backtest_create_failed"),
|
||||
SERVER_BACKTEST_UPDATE_FAILED(5604, "更新回测任务失败", "error.server.backtest_update_failed"),
|
||||
SERVER_BACKTEST_DELETE_FAILED(5605, "删除回测任务失败", "error.server.backtest_delete_failed"),
|
||||
SERVER_BACKTEST_LIST_FETCH_FAILED(5606, "查询回测列表失败", "error.server.backtest_list_fetch_failed"),
|
||||
SERVER_BACKTEST_DETAIL_FETCH_FAILED(5607, "查询回测详情失败", "error.server.backtest_detail_fetch_failed"),
|
||||
SERVER_BACKTEST_TRADES_FETCH_FAILED(5608, "查询回测交易记录失败", "error.server.backtest_trades_fetch_failed"),
|
||||
SERVER_BACKTEST_EXECUTE_FAILED(5609, "回测执行失败", "error.server.backtest_execute_failed"),
|
||||
SERVER_BACKTEST_HISTORICAL_DATA_FETCH_FAILED(5610, "历史数据获取失败", "error.server.backtest_historical_data_fetch_failed"),
|
||||
SERVER_BACKTEST_STOP_FAILED(5611, "停止回测任务失败", "error.server.backtest_stop_failed"),
|
||||
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
|
||||
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed"),
|
||||
|
||||
// 加密价差策略服务 (5620-5629)
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED(5620, "创建加密价差策略失败", "error.server.crypto_tail_strategy_create_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED(5621, "更新加密价差策略失败", "error.server.crypto_tail_strategy_update_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED(5622, "删除加密价差策略失败", "error.server.crypto_tail_strategy_delete_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED(5623, "查询加密价差策略列表失败", "error.server.crypto_tail_strategy_list_fetch_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
/**
|
||||
* 价差方向枚举
|
||||
*/
|
||||
enum class SpreadDirection(val value: Int, val description: String) {
|
||||
/**
|
||||
* 最小价差:价差 >= 配置值时触发,买入价固定 0.99
|
||||
*/
|
||||
MIN(0, "最小价差"),
|
||||
|
||||
/**
|
||||
* 最大价差:价差 <= 配置值时触发,买入价 = 触发价 + 0.02
|
||||
*/
|
||||
MAX(1, "最大价差");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 从数值解析价差方向
|
||||
*/
|
||||
fun fromValue(value: Int?): SpreadDirection {
|
||||
if (value == null) {
|
||||
return MIN // 默认返回 MIN
|
||||
}
|
||||
return values().find { it.value == value }
|
||||
?: throw IllegalArgumentException("未知的价差方向: $value")
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地从数值解析价差方向,解析失败返回默认值
|
||||
*/
|
||||
fun fromValueOrDefault(value: Int?, default: SpreadDirection = MIN): SpreadDirection {
|
||||
if (value == null) {
|
||||
return default
|
||||
}
|
||||
return values().find { it.value == value } ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符串解析价差方向(兼容旧逻辑)
|
||||
*/
|
||||
fun fromString(value: String?): SpreadDirection {
|
||||
if (value.isNullOrBlank()) {
|
||||
return MIN
|
||||
}
|
||||
return values().find { it.name.equals(value, ignoreCase = true) }
|
||||
?: throw IllegalArgumentException("未知的价差方向: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
import jakarta.persistence.AttributeConverter
|
||||
import jakarta.persistence.Converter
|
||||
|
||||
/**
|
||||
* SpreadDirection 枚举的 JPA 转换器
|
||||
* 数据库存储为 TINYINT (0 = MIN, 1 = MAX)
|
||||
*/
|
||||
@Converter(autoApply = false)
|
||||
class SpreadDirectionConverter : AttributeConverter<SpreadDirection, Int> {
|
||||
|
||||
override fun convertToDatabaseColumn(attribute: SpreadDirection?): Int {
|
||||
return attribute?.value ?: SpreadDirection.MIN.value
|
||||
}
|
||||
|
||||
override fun convertToEntityAttribute(dbData: Int?): SpreadDirection {
|
||||
return SpreadDirection.fromValueOrDefault(dbData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
/**
|
||||
* 价差模式枚举
|
||||
*/
|
||||
enum class SpreadMode(val value: Int, val description: String) {
|
||||
/**
|
||||
* 不校验价差
|
||||
*/
|
||||
NONE(0, "无"),
|
||||
|
||||
/**
|
||||
* 固定值:用户输入一个数值
|
||||
*/
|
||||
FIXED(1, "固定"),
|
||||
|
||||
/**
|
||||
* 自动:系统按历史 K 线计算建议价差
|
||||
*/
|
||||
AUTO(2, "自动");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 从数值解析价差模式
|
||||
*/
|
||||
fun fromValue(value: Int?): SpreadMode {
|
||||
if (value == null) {
|
||||
return NONE // 默认返回 NONE
|
||||
}
|
||||
return values().find { it.value == value }
|
||||
?: throw IllegalArgumentException("未知的价差模式: $value")
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地从数值解析价差模式,解析失败返回默认值
|
||||
*/
|
||||
fun fromValueOrDefault(value: Int?, default: SpreadMode = NONE): SpreadMode {
|
||||
if (value == null) {
|
||||
return default
|
||||
}
|
||||
return values().find { it.value == value } ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符串解析价差模式(兼容旧逻辑)
|
||||
*/
|
||||
fun fromString(value: String?): SpreadMode {
|
||||
if (value.isNullOrBlank()) {
|
||||
return NONE
|
||||
}
|
||||
return values().find { it.name.equals(value, ignoreCase = true) }
|
||||
?: throw IllegalArgumentException("未知的价差模式: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
import jakarta.persistence.AttributeConverter
|
||||
import jakarta.persistence.Converter
|
||||
|
||||
/**
|
||||
* SpreadMode 枚举的 JPA 转换器
|
||||
* 数据库存储为 TINYINT (0 = NONE, 1 = FIXED, 2 = AUTO)
|
||||
*/
|
||||
@Converter(autoApply = false)
|
||||
class SpreadModeConverter : AttributeConverter<SpreadMode, Int> {
|
||||
|
||||
override fun convertToDatabaseColumn(attribute: SpreadMode?): Int {
|
||||
return attribute?.value ?: SpreadMode.NONE.value
|
||||
}
|
||||
|
||||
override fun convertToEntityAttribute(dbData: Int?): SpreadMode {
|
||||
return SpreadMode.fromValueOrDefault(dbData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
/**
|
||||
* 钱包类型枚举
|
||||
*/
|
||||
enum class WalletType(val value: String, val description: String) {
|
||||
/**
|
||||
* Magic 钱包(邮箱/OAuth 登录)
|
||||
* 使用 PROXY 代理合约,通过 Builder Relayer 执行 Gasless 交易
|
||||
*/
|
||||
MAGIC("magic", "Magic(邮箱/OAuth登录)"),
|
||||
|
||||
/**
|
||||
* Safe 钱包(MetaMask 等 Web3 钱包)
|
||||
* 使用 Gnosis Safe 代理合约,支持 Builder Relayer Gasless 或手动交易
|
||||
*/
|
||||
SAFE("safe", "Safe(Web3钱包)");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 从字符串值解析钱包类型(不区分大小写)
|
||||
*/
|
||||
fun fromString(value: String?): WalletType {
|
||||
if (value.isNullOrBlank()) {
|
||||
return SAFE // 默认返回 SAFE
|
||||
}
|
||||
return values().find { it.value.equals(value, ignoreCase = true) }
|
||||
?: throw IllegalArgumentException("未知的钱包类型: $value")
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地从字符串值解析钱包类型(不区分大小写),解析失败返回默认值
|
||||
*/
|
||||
fun fromStringOrDefault(value: String?, default: WalletType = SAFE): WalletType {
|
||||
if (value.isNullOrBlank()) {
|
||||
return default
|
||||
}
|
||||
return values().find { it.value.equals(value, ignoreCase = true) } ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查字符串是否为有效的钱包类型
|
||||
*/
|
||||
fun isValid(value: String?): Boolean {
|
||||
if (value.isNullOrBlank()) {
|
||||
return false
|
||||
}
|
||||
return values().any { it.value.equals(value, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.wrbug.polymarketbot.event
|
||||
|
||||
import org.springframework.context.ApplicationEvent
|
||||
|
||||
/**
|
||||
* 加密价差策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。
|
||||
*/
|
||||
class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
|
||||
@@ -29,5 +29,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
|
||||
* 检查钱包地址是否存在
|
||||
*/
|
||||
fun existsByWalletAddress(walletAddress: String): Boolean
|
||||
|
||||
/**
|
||||
* 检查代理地址是否存在
|
||||
*/
|
||||
fun existsByProxyAddress(proxyAddress: String): Boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
* 回测任务Repository
|
||||
*/
|
||||
@Repository
|
||||
interface BacktestTaskRepository : JpaRepository<BacktestTask, Long> {
|
||||
|
||||
/**
|
||||
* 根据 Leader ID 查询回测任务
|
||||
*/
|
||||
fun findByLeaderId(leaderId: Long): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 根据状态查询回测任务
|
||||
*/
|
||||
fun findByStatus(status: String): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 根据 Leader ID 和状态查询回测任务
|
||||
*/
|
||||
fun findByLeaderIdAndStatus(leaderId: Long, status: String): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 根据 Leader ID、收益率排序查询
|
||||
*/
|
||||
@Query("SELECT t FROM BacktestTask t WHERE t.leaderId = :leaderId AND t.status = :status ORDER BY t.profitRate DESC")
|
||||
fun findByLeaderIdAndStatusOrderByProfitRateDesc(leaderId: Long, status: String): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 根据状态和创建时间倒序查询
|
||||
*/
|
||||
@Query("SELECT t FROM BacktestTask t WHERE t.status = :status ORDER BY t.createdAt DESC")
|
||||
fun findByStatusOrderByCreatedAtDesc(status: String): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 更新回测任务状态
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BacktestTask t SET t.status = :status, t.updatedAt = :updatedAt WHERE t.id = :id")
|
||||
fun updateStatus(id: Long, status: String, updatedAt: Long = System.currentTimeMillis())
|
||||
|
||||
/**
|
||||
* 更新回测任务状态和错误信息
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BacktestTask t SET t.status = :status, t.errorMessage = :errorMessage, t.updatedAt = :updatedAt WHERE t.id = :id")
|
||||
fun updateStatusAndError(id: Long, status: String, errorMessage: String?, updatedAt: Long = System.currentTimeMillis())
|
||||
|
||||
/**
|
||||
* 更新回测任务进度
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BacktestTask t SET t.progress = :progress, t.updatedAt = :updatedAt WHERE t.id = :id")
|
||||
fun updateProgress(id: Long, progress: Int, updatedAt: Long = System.currentTimeMillis())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.BacktestTrade
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
* 回测交易记录Repository
|
||||
*/
|
||||
@Repository
|
||||
interface BacktestTradeRepository : JpaRepository<BacktestTrade, Long> {
|
||||
|
||||
/**
|
||||
* 根据回测任务ID查询所有交易记录
|
||||
*/
|
||||
fun findByBacktestTaskIdOrderByTradeTime(backtestTaskId: Long): List<BacktestTrade>
|
||||
|
||||
/**
|
||||
* 根据回测任务ID分页查询交易记录
|
||||
*/
|
||||
@Query("SELECT t FROM BacktestTrade t WHERE t.backtestTaskId = :backtestTaskId ORDER BY t.tradeTime")
|
||||
fun findByBacktestTaskId(
|
||||
backtestTaskId: Long,
|
||||
pageable: org.springframework.data.domain.Pageable
|
||||
): org.springframework.data.domain.Page<BacktestTrade>
|
||||
|
||||
/**
|
||||
* 根据回测任务ID统计交易数量
|
||||
*/
|
||||
fun countByBacktestTaskId(backtestTaskId: Long): Long
|
||||
|
||||
/**
|
||||
* 删除回测任务的所有交易记录(由级联删除处理)
|
||||
*/
|
||||
fun deleteByBacktestTaskId(backtestTaskId: Long)
|
||||
}
|
||||
|
||||
+4
-8
@@ -75,15 +75,11 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
||||
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.remainingQuantity > 0")
|
||||
fun sumCurrentPositionValueByMarket(copyTradingId: Long, marketId: String): BigDecimal?
|
||||
@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?
|
||||
|
||||
/**
|
||||
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CryptoTailStrategyRepository : JpaRepository<CryptoTailStrategy, Long> {
|
||||
|
||||
fun findAllByAccountId(accountId: Long): List<CryptoTailStrategy>
|
||||
fun findAllByEnabledTrue(): List<CryptoTailStrategy>
|
||||
fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List<CryptoTailStrategy>
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.math.BigDecimal
|
||||
|
||||
interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategyTrigger, Long> {
|
||||
|
||||
fun findByStrategyIdAndPeriodStartUnix(strategyId: Long, periodStartUnix: Long): CryptoTailStrategyTrigger?
|
||||
fun findAllByStrategyIdOrderByCreatedAtDesc(strategyId: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long
|
||||
|
||||
fun findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, startInclusive: Long, endInclusive: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, status: String, startInclusive: Long, endInclusive: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun countByStrategyIdAndCreatedAtBetween(strategyId: Long, startInclusive: Long, endInclusive: Long): Long
|
||||
fun countByStrategyIdAndStatusAndCreatedAtBetween(strategyId: Long, status: String, startInclusive: Long, endInclusive: Long): Long
|
||||
|
||||
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
|
||||
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
|
||||
|
||||
/** 根据订单 ID 查询加密价差策略触发记录 */
|
||||
fun findByOrderId(orderId: String): CryptoTailStrategyTrigger?
|
||||
|
||||
/** 轮询发 TG:status=success、orderId 非空、未发过通知,按创建时间正序 */
|
||||
fun findByStatusAndOrderIdIsNotNullAndNotificationSentFalseOrderByCreatedAtAsc(status: String): List<CryptoTailStrategyTrigger>
|
||||
|
||||
/** 策略已结算订单的总已实现盈亏(用于收益统计) */
|
||||
@Query("SELECT COALESCE(SUM(t.realizedPnl), 0) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true")
|
||||
fun sumRealizedPnlByStrategyId(@Param("strategyId") strategyId: Long): BigDecimal?
|
||||
|
||||
/** 策略已结算订单笔数(用于胜率分母) */
|
||||
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true")
|
||||
fun countResolvedByStrategyId(@Param("strategyId") strategyId: Long): Long
|
||||
|
||||
/** 策略已结算中赢的笔数(outcome_index = winner_outcome_index) */
|
||||
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true AND t.outcomeIndex = t.winnerOutcomeIndex")
|
||||
fun countWinsByStrategyId(@Param("strategyId") strategyId: Long): Long
|
||||
|
||||
/** 收益曲线:已结算记录,按结算时间(无则创建时间)在区间内升序 */
|
||||
@Query(
|
||||
"SELECT t FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true " +
|
||||
"AND COALESCE(t.settledAt, t.createdAt) >= :start AND COALESCE(t.settledAt, t.createdAt) <= :end " +
|
||||
"ORDER BY COALESCE(t.settledAt, t.createdAt) ASC"
|
||||
)
|
||||
fun findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc(
|
||||
@Param("strategyId") strategyId: Long,
|
||||
@Param("start") start: Long,
|
||||
@Param("end") end: Long
|
||||
): List<CryptoTailStrategyTrigger>
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.NotificationTemplate
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface NotificationTemplateRepository : JpaRepository<NotificationTemplate, Long> {
|
||||
fun findByTemplateType(templateType: String): NotificationTemplate?
|
||||
fun existsByTemplateType(templateType: String): Boolean
|
||||
}
|
||||
+592
-121
@@ -3,12 +3,17 @@ package com.wrbug.polymarketbot.service.accounts
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.eq
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.getEventSlug
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
@@ -66,11 +71,6 @@ class AccountService(
|
||||
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
||||
}
|
||||
|
||||
// 2. 检查地址是否已存在
|
||||
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
|
||||
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
|
||||
}
|
||||
|
||||
// 3. 验证私钥和地址的对应关系
|
||||
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
|
||||
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
|
||||
@@ -104,7 +104,8 @@ class AccountService(
|
||||
// 5. 获取代理地址(必须成功,否则导入失败)
|
||||
// 根据用户选择的钱包类型计算代理地址
|
||||
val proxyAddress = runBlocking {
|
||||
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
|
||||
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, walletTypeEnum)
|
||||
if (proxyResult.isSuccess) {
|
||||
val address = proxyResult.getOrNull()
|
||||
if (address != null) {
|
||||
@@ -120,25 +121,31 @@ class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 按代理地址去重:该代理地址已存在则不允许重复导入
|
||||
if (accountRepository.existsByProxyAddress(proxyAddress)) {
|
||||
return Result.failure(IllegalArgumentException("ACCOUNT_ALREADY_EXISTS"))
|
||||
}
|
||||
|
||||
// 7. 加密敏感信息
|
||||
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
||||
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedApiSecret = apiKeyCreds.secret.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedApiPassphrase = apiKeyCreds.passphrase.let { cryptoUtils.encrypt(it) }
|
||||
|
||||
// 8. 生成账户名称(如果未提供,使用钱包地址后四位)
|
||||
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
|
||||
val accountName = if (request.accountName.isNullOrBlank()) {
|
||||
val walletAddress = request.walletAddress.trim()
|
||||
// 取地址后四位(去掉 0x 前缀后取后四位)
|
||||
val addressWithoutPrefix = if (walletAddress.startsWith("0x") || walletAddress.startsWith("0X")) {
|
||||
walletAddress.substring(2)
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
|
||||
val typeLabel = walletTypeEnum.name.uppercase()
|
||||
val proxyWithoutPrefix = if (proxyAddress.startsWith("0x") || proxyAddress.startsWith("0X")) {
|
||||
proxyAddress.substring(2)
|
||||
} else {
|
||||
walletAddress
|
||||
proxyAddress
|
||||
}
|
||||
if (addressWithoutPrefix.length >= 4) {
|
||||
addressWithoutPrefix.substring(addressWithoutPrefix.length - 4).uppercase()
|
||||
val suffix = if (proxyWithoutPrefix.length >= 4) {
|
||||
proxyWithoutPrefix.substring(proxyWithoutPrefix.length - 4).uppercase()
|
||||
} else {
|
||||
addressWithoutPrefix.uppercase()
|
||||
proxyWithoutPrefix.uppercase()
|
||||
}
|
||||
"$typeLabel-$suffix"
|
||||
} else {
|
||||
request.accountName.trim()
|
||||
}
|
||||
@@ -171,6 +178,401 @@ class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理地址选项(用于账户导入前选择代理类型)
|
||||
* 私钥导入:返回 Magic 和 Safe 两个选项
|
||||
* 助记词导入:仅返回 Safe 选项
|
||||
*/
|
||||
suspend fun checkProxyOptions(request: CheckProxyOptionsRequest): Result<CheckProxyOptionsResponse> {
|
||||
return try {
|
||||
// 1. 验证钱包地址格式
|
||||
if (!isValidWalletAddress(request.walletAddress)) {
|
||||
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
||||
}
|
||||
|
||||
// 2. 验证至少提供了私钥或助记词之一
|
||||
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
|
||||
return Result.failure(IllegalArgumentException("必须提供私钥或助记词"))
|
||||
}
|
||||
|
||||
val options = mutableListOf<ProxyOptionDto>()
|
||||
|
||||
// 3. 判断导入类型
|
||||
val isPrivateKeyImport = !request.privateKey.isNullOrBlank()
|
||||
|
||||
if (isPrivateKeyImport) {
|
||||
// 私钥导入:并行获取 Magic 和 Safe 代理地址及资产
|
||||
coroutineScope {
|
||||
val magicDeferred = async {
|
||||
try {
|
||||
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.MAGIC).getOrNull()
|
||||
if (proxyAddress != null) {
|
||||
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
|
||||
ProxyOptionDto(
|
||||
walletType = WalletType.MAGIC.value,
|
||||
proxyAddress = proxyAddress,
|
||||
descriptionKey = "accountImport.proxyOption.magic.description",
|
||||
availableBalance = balance?.availableBalance ?: "0",
|
||||
positionBalance = balance?.positionBalance ?: "0",
|
||||
totalBalance = balance?.totalBalance ?: "0",
|
||||
positionCount = balance?.positions?.size ?: 0,
|
||||
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positions?.isNotEmpty() == true),
|
||||
error = null
|
||||
)
|
||||
} else {
|
||||
ProxyOptionDto(
|
||||
walletType = "magic",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.magic.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取 Magic 代理地址失败"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 Magic 代理地址或资产失败: ${e.message}", e)
|
||||
ProxyOptionDto(
|
||||
walletType = "magic",
|
||||
proxyAddress = blockchainService.calculateMagicProxyAddress(request.walletAddress),
|
||||
descriptionKey = "accountImport.proxyOption.magic.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取资产信息失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val safeDeferred = async {
|
||||
try {
|
||||
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
|
||||
if (proxyAddress != null) {
|
||||
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
|
||||
ProxyOptionDto(
|
||||
walletType = WalletType.SAFE.value,
|
||||
proxyAddress = proxyAddress,
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = balance?.availableBalance ?: "0",
|
||||
positionBalance = balance?.positionBalance ?: "0",
|
||||
totalBalance = balance?.totalBalance ?: "0",
|
||||
positionCount = balance?.positions?.size ?: 0,
|
||||
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positions?.isNotEmpty() == true),
|
||||
error = null
|
||||
)
|
||||
} else {
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取 Safe 代理地址失败"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取资产信息失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val magicOption = magicDeferred.await()
|
||||
val safeOption = safeDeferred.await()
|
||||
// Safe 在前,Magic 在后
|
||||
options.add(safeOption)
|
||||
options.add(magicOption)
|
||||
}
|
||||
} else {
|
||||
// 助记词导入:仅获取 Safe 代理地址及资产
|
||||
try {
|
||||
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
|
||||
if (proxyAddress != null) {
|
||||
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
|
||||
options.add(
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = proxyAddress,
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = balance?.availableBalance ?: "0",
|
||||
positionBalance = balance?.positionBalance ?: "0",
|
||||
totalBalance = balance?.totalBalance ?: "0",
|
||||
positionCount = balance?.positions?.size ?: 0,
|
||||
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positions?.isNotEmpty() == true),
|
||||
error = null
|
||||
)
|
||||
)
|
||||
} else {
|
||||
options.add(
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取 Safe 代理地址失败"
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
|
||||
options.add(
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取资产信息失败: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(CheckProxyOptionsResponse(options = options))
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查代理地址选项失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polymarket 代币批准检查:USDC.e 需授权的 spender 合约地址(Polygon 主网)
|
||||
* 来源:Polymarket/magic-safe-builder-example README §6 Token Approvals
|
||||
* 及 neg-risk-ctf-adapter 仓库 addresses.json (chainId 137)
|
||||
*/
|
||||
private val setupApprovalSpenders = mapOf(
|
||||
"CTF_CONTRACT" to "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", // Conditional Tokens
|
||||
"CTF_EXCHANGE" to "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E", // 普通市场交易所
|
||||
"NEG_RISK_EXCHANGE" to "0xC5d563A36AE78145C45a50134d48A1215220f80a", // 负风险市场交易所
|
||||
"NEG_RISK_ADAPTER" to "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" // 负风险适配器(非 WCOL 地址)
|
||||
)
|
||||
|
||||
/** USDC 精度(6 位小数) */
|
||||
private val usdcDecimals = java.math.BigDecimal("1000000")
|
||||
|
||||
/** ERC20 无限授权额度(type(uint256).max),Polymarket 默认使用无限授权 */
|
||||
private val unlimitedAllowance = BigInteger("115792089237316195423570985008687907853269984665640564039457584007913129639935")
|
||||
|
||||
/**
|
||||
* 检查账户设置状态(代理部署、交易启用、代币批准)
|
||||
* @param accountId 账户 ID
|
||||
* @return AccountSetupStatusDto
|
||||
*/
|
||||
suspend fun checkAccountSetupStatus(accountId: Long): Result<AccountSetupStatusDto> {
|
||||
return try {
|
||||
if (accountId <= 0) {
|
||||
return Result.failure(IllegalArgumentException("账户 ID 无效"))
|
||||
}
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
val proxyAddress = account.proxyAddress
|
||||
if (proxyAddress.isBlank()) {
|
||||
return Result.success(
|
||||
AccountSetupStatusDto(
|
||||
proxyDeployed = false,
|
||||
tradingEnabled = account.apiKey != null && account.apiSecret != null && account.apiPassphrase != null,
|
||||
tokensApproved = false,
|
||||
approvalDetails = null,
|
||||
error = "代理地址为空"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 步骤1:代理钱包是否已部署
|
||||
val proxyDeployed = blockchainService.isProxyDeployed(proxyAddress)
|
||||
|
||||
// 步骤2:交易是否已启用(API 凭证是否已配置)
|
||||
val tradingEnabled = account.apiKey != null &&
|
||||
account.apiSecret != null &&
|
||||
account.apiPassphrase != null
|
||||
|
||||
// 步骤3:代币是否已批准(USDC 对各 spender 的 allowance,默认无限授权)
|
||||
val approvalDetails = mutableMapOf<String, String>()
|
||||
var tokensApproved = true
|
||||
for ((name, spender) in setupApprovalSpenders) {
|
||||
val allowanceResult = blockchainService.getUsdcAllowance(proxyAddress, spender)
|
||||
val allowance = allowanceResult.getOrNull() ?: BigInteger.ZERO
|
||||
val displayAmount = if (allowance >= unlimitedAllowance) {
|
||||
"unlimited"
|
||||
} else {
|
||||
java.math.BigDecimal(allowance).divide(usdcDecimals, 6, java.math.RoundingMode.DOWN).toPlainString()
|
||||
}
|
||||
approvalDetails[name] = displayAmount
|
||||
if (allowance <= BigInteger.ZERO) {
|
||||
tokensApproved = false
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(
|
||||
AccountSetupStatusDto(
|
||||
proxyDeployed = proxyDeployed,
|
||||
tradingEnabled = tradingEnabled,
|
||||
tokensApproved = tokensApproved,
|
||||
approvalDetails = approvalDetails,
|
||||
error = null
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查账户设置状态失败: accountId=$accountId, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 步骤1 跳转 URL(代理部署需在 Polymarket 完成) */
|
||||
private val setupStep1RedirectUrl = "https://polymarket.com/settings/wallet"
|
||||
|
||||
/**
|
||||
* 执行设置步骤(由后端实现或返回跳转)
|
||||
* 步骤1:仅返回跳转 URL,由用户前往 Polymarket 完成部署
|
||||
* 步骤2:创建/派生 API Key 并更新账户
|
||||
* 步骤3:通过代理钱包批量执行 USDC 授权
|
||||
*/
|
||||
suspend fun executeSetupStep(accountId: Long, step: Int): Result<ExecuteSetupStepResponse> {
|
||||
return try {
|
||||
if (accountId <= 0) {
|
||||
return Result.failure(IllegalArgumentException("账户 ID 无效"))
|
||||
}
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
when (step) {
|
||||
1 -> {
|
||||
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.MAGIC)
|
||||
if (walletType == WalletType.MAGIC) {
|
||||
Result.success(
|
||||
ExecuteSetupStepResponse(
|
||||
success = false,
|
||||
redirectUrl = setupStep1RedirectUrl
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val proxyAddress = account.proxyAddress
|
||||
if (proxyAddress.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("代理地址为空"))
|
||||
}
|
||||
val alreadyDeployed = blockchainService.isProxyDeployed(proxyAddress)
|
||||
if (alreadyDeployed) {
|
||||
Result.success(ExecuteSetupStepResponse(success = true))
|
||||
} else {
|
||||
val privateKey = decryptPrivateKey(account)
|
||||
val deployResult = relayClientService.deploySafeViaBuilderRelayer(
|
||||
privateKey = privateKey,
|
||||
proxyAddress = proxyAddress,
|
||||
fromAddress = account.walletAddress
|
||||
)
|
||||
deployResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
Result.success(
|
||||
ExecuteSetupStepResponse(
|
||||
success = true,
|
||||
transactionHash = txHash
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("Safe 部署失败: accountId=$accountId, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
2 -> {
|
||||
val privateKey = decryptPrivateKey(account)
|
||||
val result = apiKeyService.createOrDeriveApiKey(
|
||||
privateKey = privateKey,
|
||||
walletAddress = account.walletAddress,
|
||||
chainId = 137L
|
||||
)
|
||||
if (result.isFailure) {
|
||||
val e = result.exceptionOrNull()
|
||||
logger.error("启用交易(API Key)失败: accountId=$accountId, ${e?.message}", e)
|
||||
return Result.failure(e ?: IllegalStateException("获取 API Key 失败"))
|
||||
}
|
||||
val creds = result.getOrNull()
|
||||
?: return Result.failure(IllegalStateException("API Key 返回为空"))
|
||||
val encryptedSecret = creds.secret.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedPassphrase = creds.passphrase.let { cryptoUtils.encrypt(it) }
|
||||
val updated = account.copy(
|
||||
apiKey = creds.apiKey,
|
||||
apiSecret = encryptedSecret,
|
||||
apiPassphrase = encryptedPassphrase,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
accountRepository.save(updated)
|
||||
orderPushService.refreshSubscriptions()
|
||||
Result.success(ExecuteSetupStepResponse(success = true))
|
||||
}
|
||||
3 -> {
|
||||
val proxyAddress = account.proxyAddress
|
||||
if (proxyAddress.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("代理地址为空,请先完成步骤1"))
|
||||
}
|
||||
val privateKey = decryptPrivateKey(account)
|
||||
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
val approveTxs = setupApprovalSpenders.values.map { spender ->
|
||||
relayClientService.createUsdcApproveTx(spender, unlimitedAllowance)
|
||||
}
|
||||
val multiSendTx = relayClientService.createMultiSendTx(approveTxs)
|
||||
val executeResult = relayClientService.execute(
|
||||
privateKey = privateKey,
|
||||
proxyAddress = proxyAddress,
|
||||
safeTx = multiSendTx,
|
||||
walletType = walletType
|
||||
)
|
||||
executeResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
Result.success(
|
||||
ExecuteSetupStepResponse(
|
||||
success = true,
|
||||
transactionHash = txHash
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("代币授权执行失败: accountId=$accountId, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
}
|
||||
else -> Result.failure(IllegalArgumentException("无效的步骤: $step,应为 1、2 或 3"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("执行设置步骤失败: accountId=$accountId, step=$step, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
@@ -278,7 +680,7 @@ class AccountService(
|
||||
if (accountId == null) {
|
||||
return Result.failure(IllegalArgumentException("账户ID不能为空"))
|
||||
}
|
||||
|
||||
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
@@ -288,68 +690,19 @@ class AccountService(
|
||||
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
|
||||
}
|
||||
|
||||
// 查询 USDC 余额和持仓信息
|
||||
// 使用通用方法查询余额
|
||||
val balanceResult = runBlocking {
|
||||
try {
|
||||
// 查询持仓信息(用于返回持仓列表)
|
||||
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
|
||||
val positionsResult = blockchainService.getPositions(account.proxyAddress)
|
||||
val positions = if (positionsResult.isSuccess) {
|
||||
positionsResult.getOrNull()?.map { pos ->
|
||||
PositionDto(
|
||||
marketId = pos.conditionId ?: "",
|
||||
side = pos.outcome ?: "",
|
||||
quantity = pos.size?.toString() ?: "0",
|
||||
avgPrice = pos.avgPrice?.toString() ?: "0",
|
||||
currentValue = pos.currentValue?.toString() ?: "0",
|
||||
pnl = pos.cashPnl?.toString()
|
||||
)
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// 使用 /value 接口获取仓位总价值(而不是累加)
|
||||
val positionBalanceResult = blockchainService.getTotalValue(account.proxyAddress)
|
||||
val positionBalance = if (positionBalanceResult.isSuccess) {
|
||||
positionBalanceResult.getOrNull() ?: "0"
|
||||
} else {
|
||||
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
// 查询可用余额(通过 RPC 查询 USDC 余额)
|
||||
// 必须使用代理地址查询
|
||||
val availableBalanceResult = blockchainService.getUsdcBalance(
|
||||
walletAddress = account.walletAddress,
|
||||
proxyAddress = account.proxyAddress
|
||||
)
|
||||
val availableBalance = if (availableBalanceResult.isSuccess) {
|
||||
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
|
||||
} else {
|
||||
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
|
||||
val error = availableBalanceResult.exceptionOrNull()
|
||||
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
|
||||
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
|
||||
}
|
||||
|
||||
// 计算总余额 = 可用余额 + 仓位余额
|
||||
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
|
||||
|
||||
AccountBalanceResponse(
|
||||
availableBalance = availableBalance,
|
||||
positionBalance = positionBalance,
|
||||
totalBalance = totalBalance.toPlainString(),
|
||||
positions = positions
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询余额失败: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
blockchainService.getWalletBalance(account.proxyAddress)
|
||||
}
|
||||
|
||||
Result.success(balanceResult)
|
||||
balanceResult.map { walletBalance: WalletBalanceResponse ->
|
||||
AccountBalanceResponse(
|
||||
availableBalance = walletBalance.availableBalance,
|
||||
positionBalance = walletBalance.positionBalance,
|
||||
totalBalance = walletBalance.totalBalance,
|
||||
positions = walletBalance.positions
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户余额失败", e)
|
||||
Result.failure(e)
|
||||
@@ -586,7 +939,38 @@ class AccountService(
|
||||
throw RuntimeException("解密私钥失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包为 USDC.e。
|
||||
* 由 WcolUnwrapJobService 每 20 秒调用,赎回后无需在赎回流程内等待确认与解包。
|
||||
*/
|
||||
suspend fun runWcolUnwrapForAllAccounts() {
|
||||
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
|
||||
if (accounts.isEmpty()) return
|
||||
for (account in accounts) {
|
||||
try {
|
||||
val privateKey = decryptPrivateKey(account)
|
||||
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
blockchainService.unwrapWcolForProxy(
|
||||
privateKey = privateKey,
|
||||
proxyAddress = account.proxyAddress,
|
||||
walletType = walletType
|
||||
).fold(
|
||||
onSuccess = { txHash ->
|
||||
if (txHash != null) {
|
||||
logger.info("轮询解包 WCOL: accountId=${account.id}, proxy=${account.proxyAddress.take(10)}..., txHash=$txHash")
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.warn("轮询解包 WCOL 失败 accountId=${account.id}: ${e.message}")
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("轮询解包 WCOL 跳过 accountId=${account.id}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密账户 API Secret
|
||||
*/
|
||||
@@ -747,7 +1131,7 @@ class AccountService(
|
||||
|
||||
// 3. 验证仓位是否存在并获取原始数量
|
||||
val positionsResult = getAllPositions()
|
||||
val (position, originalQuantity) = positionsResult.fold(
|
||||
val (_, originalQuantity) = positionsResult.fold(
|
||||
onSuccess = { positionListResponse ->
|
||||
val position = positionListResponse.currentPositions.find {
|
||||
it.accountId == request.accountId &&
|
||||
@@ -780,7 +1164,7 @@ class AccountService(
|
||||
onFailure = { e ->
|
||||
return Result.failure(Exception("查询仓位失败: ${e.message}"))
|
||||
}
|
||||
) ?: return Result.failure(IllegalArgumentException("仓位不存在"))
|
||||
)
|
||||
|
||||
// 4. 计算实际卖出数量
|
||||
val sellQuantity = if (percentDecimal != null) {
|
||||
@@ -876,7 +1260,7 @@ class AccountService(
|
||||
"0"
|
||||
}
|
||||
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量)
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -885,7 +1269,7 @@ class AccountService(
|
||||
side = "SELL",
|
||||
price = sellPrice,
|
||||
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
|
||||
signatureType = 2, // Browser Wallet(与正确订单数据一致)
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = expiration
|
||||
@@ -899,7 +1283,7 @@ class AccountService(
|
||||
|
||||
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey!!, // API Key
|
||||
owner = account.apiKey, // API Key
|
||||
orderType = orderType,
|
||||
deferExec = false
|
||||
)
|
||||
@@ -919,7 +1303,7 @@ class AccountService(
|
||||
}
|
||||
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey!!,
|
||||
account.apiKey,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
account.walletAddress
|
||||
@@ -950,13 +1334,22 @@ class AccountService(
|
||||
|
||||
// 使用当前时间作为订单创建时间
|
||||
val orderTime = System.currentTimeMillis()
|
||||
|
||||
// 查询可用余额
|
||||
val availableBalance = try {
|
||||
blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
telegramNotificationService?.sendOrderSuccessNotification(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = request.marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = request.side,
|
||||
side = "SELL", // 手动卖出订单,方向固定为 SELL
|
||||
outcome = request.side, // request.side 是市场方向(YES/NO)
|
||||
price = sellPrice, // 直接传递卖出价格
|
||||
size = sellQuantity.toPlainString(), // 直接传递卖出数量
|
||||
accountName = account.accountName,
|
||||
@@ -967,7 +1360,8 @@ class AccountService(
|
||||
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
|
||||
walletAddressForApi = account.walletAddress,
|
||||
locale = locale,
|
||||
orderTime = orderTime // 使用订单创建时间
|
||||
orderTime = orderTime, // 使用订单创建时间
|
||||
availableBalance = availableBalance
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送订单成功通知失败: ${e.message}", e)
|
||||
@@ -987,7 +1381,7 @@ class AccountService(
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val errorMsg = response.errorMsg ?: "未知错误"
|
||||
val errorMsg = response.getErrorMessage()
|
||||
val fullErrorMsg = "创建订单失败: accountId=${account.id}, marketId=${request.marketId}, side=${request.side}, orderType=${request.orderType}, price=${if (request.orderType == "LIMIT") sellPrice else "MARKET"}, quantity=${sellQuantity.toPlainString()}, errorMsg=$errorMsg"
|
||||
logger.error(fullErrorMsg)
|
||||
|
||||
@@ -1032,6 +1426,14 @@ class AccountService(
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// 尝试从 errorBody 解析 error 字段(使用 Gson)
|
||||
val apiError = try {
|
||||
(errorBody?.fromJson<JsonObject>()?.get("error") as? JsonPrimitive)?.asString
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
val fullErrorMsg = "创建订单失败: accountId=${account.id}, marketId=${request.marketId}, side=${request.side}, orderType=${request.orderType}, price=${if (request.orderType == "LIMIT") sellPrice else "MARKET"}, quantity=${sellQuantity.toPlainString()}, code=${orderResponse.code()}, message=${orderResponse.message()}${if (errorBody != null) ", errorBody=$errorBody" else ""}"
|
||||
logger.error(fullErrorMsg)
|
||||
|
||||
@@ -1050,8 +1452,10 @@ class AccountService(
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 只传递后端返回的 msg,不传递完整堆栈
|
||||
val errorMsg = orderResponse.body()?.errorMsg ?: "创建订单失败"
|
||||
// 优先使用解析的 API error,其次使用响应体的 errorMsg,最后使用默认消息
|
||||
val errorMsg = apiError
|
||||
?: orderResponse.body()?.getErrorMessage()
|
||||
?: "创建订单失败 (HTTP ${orderResponse.code()})"
|
||||
|
||||
telegramNotificationService?.sendOrderFailureNotification(
|
||||
marketTitle = marketTitle,
|
||||
@@ -1061,7 +1465,7 @@ class AccountService(
|
||||
outcome = null, // 失败时可能没有 outcome
|
||||
price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET",
|
||||
size = sellQuantity.toString(),
|
||||
errorMessage = errorMsg, // 只传递后端返回的 msg
|
||||
errorMessage = errorMsg, // 只传递后端返回的错误信息
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
locale = locale
|
||||
@@ -1238,13 +1642,6 @@ class AccountService(
|
||||
*/
|
||||
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
|
||||
return try {
|
||||
// 检查 Builder API Key 是否已配置
|
||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||
return Result.failure(
|
||||
IllegalStateException("Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。")
|
||||
)
|
||||
}
|
||||
|
||||
if (request.positions.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("赎回仓位列表不能为空"))
|
||||
}
|
||||
@@ -1266,7 +1663,17 @@ class AccountService(
|
||||
accounts[accountId] = account
|
||||
}
|
||||
|
||||
// 4. 验证并收集要赎回的仓位信息(按账户分组)
|
||||
// 4. 若涉及 Magic 账户,必须已配置 Builder API Key(提前判断,避免执行到深层再报错)
|
||||
val hasMagicAccount = accounts.values.any {
|
||||
WalletType.fromStringOrDefault(it.walletType, WalletType.SAFE) == WalletType.MAGIC
|
||||
}
|
||||
if (hasMagicAccount && !relayClientService.isBuilderApiKeyConfigured()) {
|
||||
return Result.failure(
|
||||
IllegalStateException("Builder API Key 未配置,无法执行 Magic 账户赎回(Gasless)。请前往系统设置页面配置 Builder API Key。")
|
||||
)
|
||||
}
|
||||
|
||||
// 5. 验证并收集要赎回的仓位信息(按账户分组)
|
||||
val accountRedeemData = mutableMapOf<Long, MutableList<Pair<AccountPositionDto, BigInteger>>>()
|
||||
val accountRedeemedInfo =
|
||||
mutableMapOf<Long, MutableList<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>>()
|
||||
@@ -1309,7 +1716,7 @@ class AccountService(
|
||||
accountRedeemedInfo[accountId] = accountInfo
|
||||
}
|
||||
|
||||
// 5. 对每个账户执行赎回
|
||||
// 6. 对每个账户执行赎回(Safe 与 Magic 均支持,Magic 通过 Builder Relayer PROXY Gasless 执行)
|
||||
val accountTransactions = mutableListOf<com.wrbug.polymarketbot.dto.AccountRedeemTransaction>()
|
||||
var totalRedeemedValue = BigDecimal.ZERO
|
||||
|
||||
@@ -1320,20 +1727,31 @@ class AccountService(
|
||||
// 按市场分组(同一市场的仓位可以批量赎回)
|
||||
val positionsByMarket = positions.groupBy { it.first.marketId }
|
||||
|
||||
// 对每个市场执行赎回
|
||||
// 获取钱包类型
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
|
||||
// 解密私钥(只需解密一次)
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 执行赎回
|
||||
var lastTxHash: String? = null
|
||||
for ((marketId, marketPositions) in positionsByMarket) {
|
||||
val indexSets = marketPositions.map { it.second }
|
||||
|
||||
// 解密私钥
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
// Safe 钱包且有多个市场:使用 MultiSend 批量赎回
|
||||
if (walletTypeEnum == WalletType.SAFE && positionsByMarket.size > 1) {
|
||||
val redeemRequests = mutableListOf<Triple<String, List<BigInteger>, Boolean>>()
|
||||
for ((marketId, marketPositions) in positionsByMarket) {
|
||||
val indexSets = marketPositions.map { it.second }
|
||||
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
|
||||
redeemRequests.add(Triple(marketId, indexSets, isNegRisk))
|
||||
}
|
||||
|
||||
// 调用区块链服务赎回仓位
|
||||
val redeemResult = blockchainService.redeemPositions(
|
||||
logger.info("账户 $accountId: 使用 MultiSend 批量赎回 ${redeemRequests.size} 个市场")
|
||||
|
||||
val redeemResult = blockchainService.redeemPositionsBatch(
|
||||
privateKey = decryptedPrivateKey,
|
||||
proxyAddress = account.proxyAddress,
|
||||
conditionId = marketId,
|
||||
indexSets = indexSets
|
||||
redeemRequests = redeemRequests,
|
||||
walletType = walletTypeEnum
|
||||
)
|
||||
|
||||
redeemResult.fold(
|
||||
@@ -1341,11 +1759,38 @@ class AccountService(
|
||||
lastTxHash = txHash
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
|
||||
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
|
||||
logger.error("账户 $accountId MultiSend 批量赎回失败: ${e.message}", e)
|
||||
return Result.failure(Exception("赎回失败: 账户 $accountId - ${e.message}"))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Magic 钱包或单个市场:逐笔赎回
|
||||
for ((marketId, marketPositions) in positionsByMarket) {
|
||||
val indexSets = marketPositions.map { it.second }
|
||||
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
|
||||
|
||||
val redeemResult = blockchainService.redeemPositions(
|
||||
privateKey = decryptedPrivateKey,
|
||||
proxyAddress = account.proxyAddress,
|
||||
conditionId = marketId,
|
||||
indexSets = indexSets,
|
||||
isNegRisk = isNegRisk,
|
||||
walletType = walletTypeEnum
|
||||
)
|
||||
|
||||
redeemResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
lastTxHash = txHash
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
|
||||
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// WCOL 解包由 WcolUnwrapJobService 每 20 秒轮询统一处理,赎回流程不再等待确认与解包
|
||||
|
||||
// 计算该账户的赎回总价值
|
||||
val accountTotalValue = redeemedInfo.fold(BigDecimal.ZERO) { sum, info ->
|
||||
@@ -1364,7 +1809,7 @@ class AccountService(
|
||||
)
|
||||
}
|
||||
|
||||
// 6. 发送赎回推送通知(异步,不阻塞)
|
||||
// 7. 发送赎回推送通知(异步,不阻塞)
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取当前语言设置
|
||||
@@ -1378,16 +1823,42 @@ class AccountService(
|
||||
for (transaction in accountTransactions) {
|
||||
val account = accounts[transaction.accountId]
|
||||
if (account != null) {
|
||||
telegramNotificationService?.sendRedeemNotification(
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
transactionHash = transaction.transactionHash,
|
||||
totalRedeemedValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info ->
|
||||
sum.add(info.value.toSafeBigDecimal())
|
||||
}.toPlainString(),
|
||||
positions = transaction.positions,
|
||||
locale = locale
|
||||
)
|
||||
// 查询可用余额
|
||||
val availableBalance = try {
|
||||
blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
// 计算该账户的赎回总价值
|
||||
val accountTotalValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info ->
|
||||
sum.add(info.value.toSafeBigDecimal())
|
||||
}
|
||||
|
||||
// 根据赎回价值选择不同的通知类型
|
||||
if (accountTotalValue.gt(BigDecimal.ZERO)) {
|
||||
// 有收益:发送赎回成功通知
|
||||
telegramNotificationService?.sendRedeemNotification(
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
transactionHash = transaction.transactionHash,
|
||||
totalRedeemedValue = accountTotalValue.toPlainString(),
|
||||
positions = transaction.positions,
|
||||
locale = locale,
|
||||
availableBalance = availableBalance
|
||||
)
|
||||
} else {
|
||||
// 无收益(输的仓位):发送已结算无收益通知
|
||||
telegramNotificationService?.sendRedeemNoReturnNotification(
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
transactionHash = transaction.transactionHash,
|
||||
positions = transaction.positions,
|
||||
locale = locale,
|
||||
availableBalance = availableBalance
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
+36
-29
@@ -25,6 +25,7 @@ import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* 仓位检查服务
|
||||
@@ -77,7 +78,10 @@ class PositionCheckService(
|
||||
|
||||
// 同步锁,确保订阅任务的启动和停止是线程安全的
|
||||
private val lock = Any()
|
||||
|
||||
|
||||
// 防止 checkRedeemablePositions 重入:上一轮检查未完成时,新一轮轮询直接跳过
|
||||
private val redeemCheckInProgress = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务)
|
||||
*/
|
||||
@@ -328,18 +332,23 @@ class PositionCheckService(
|
||||
|
||||
/**
|
||||
* 逻辑1:处理待赎回仓位
|
||||
https://clob.polymarket.com * 按照以下逻辑处理:
|
||||
* 按照以下逻辑处理:
|
||||
* 1. 无待赎回仓位:跳过
|
||||
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
|
||||
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
||||
* 防重入:上一轮检查未完成时,本轮直接跳过,避免并发赎回。
|
||||
*/
|
||||
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
|
||||
if (!redeemCheckInProgress.compareAndSet(false, true)) {
|
||||
logger.debug("跳过本次待赎回仓位检查:上一次检查尚未完成")
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 1. 无待赎回仓位:跳过
|
||||
if (redeemablePositions.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 检查系统级别的自动赎回配置
|
||||
val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled()
|
||||
val apiKeyConfigured = relayClientService.isBuilderApiKeyConfigured()
|
||||
@@ -373,21 +382,24 @@ class PositionCheckService(
|
||||
}
|
||||
return // 未配置时直接返回,不进行后续处理
|
||||
}
|
||||
|
||||
|
||||
// Builder Relayer 配额冷却期内不再发起赎回(如 API 返回 quota exceeded, resets in N seconds)
|
||||
if (relayClientService.isBuilderRelayerQuotaBlocked()) {
|
||||
val remaining = relayClientService.getBuilderRelayerQuotaBlockedRemainingSeconds()
|
||||
logger.info("Builder Relayer 配额冷却中,跳过本次自动赎回,约 ${remaining} 秒后恢复")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
||||
// 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理
|
||||
// 先执行赎回,赎回成功后再查找订单并更新订单状态
|
||||
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
|
||||
|
||||
for ((accountId, positions) in positionsByAccount) {
|
||||
// 查找该账户下所有启用的跟单配置
|
||||
// 查找该账户下所有启用的跟单配置(仅用于赎回成功后更新跟单订单状态;无跟单配置的账户如加密价差策略账户也会执行赎回)
|
||||
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
||||
.filter { it.enabled }
|
||||
|
||||
if (copyTradings.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 过滤掉已经处理过的仓位(去重,避免重复赎回)
|
||||
val now = System.currentTimeMillis()
|
||||
val positionsToRedeem = positions.filter { position ->
|
||||
@@ -430,27 +442,20 @@ class PositionCheckService(
|
||||
processedRedeemablePositions[positionKey] = now
|
||||
}
|
||||
|
||||
// 赎回成功后,再查找订单并更新订单状态
|
||||
// 赎回成功后,按每个跟单配置分别查找未卖出订单并更新状态
|
||||
// 同一账户同一市场可能同时跟多个 Leader,需按 copyTradingId 分别生成自动卖出记录(如 leader1 对应 20 share,leader2 对应 16 share)
|
||||
for (position in positionsToRedeem) {
|
||||
// 查找相同仓位的未卖出订单(remaining_quantity > 0)
|
||||
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
|
||||
for (copyTrading in copyTradings) {
|
||||
if (position.outcomeIndex != null) {
|
||||
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||
copyTrading.id!!,
|
||||
position.marketId,
|
||||
position.outcomeIndex
|
||||
)
|
||||
unmatchedOrders.addAll(orders)
|
||||
}
|
||||
if (position.outcomeIndex == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果有未卖出订单,更新订单状态
|
||||
if (unmatchedOrders.isNotEmpty()) {
|
||||
// 从订单中获取 copyTradingId(所有订单应该有相同的 copyTradingId)
|
||||
val copyTradingId = unmatchedOrders.firstOrNull()?.copyTradingId
|
||||
if (copyTradingId != null) {
|
||||
updateOrdersAsSoldAfterRedeem(unmatchedOrders, position, copyTradingId)
|
||||
for (copyTrading in copyTradings) {
|
||||
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||
copyTrading.id!!,
|
||||
position.marketId,
|
||||
position.outcomeIndex
|
||||
)
|
||||
if (orders.isNotEmpty()) {
|
||||
updateOrdersAsSoldAfterRedeem(orders, position, copyTrading.id!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -462,9 +467,11 @@ class PositionCheckService(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理待赎回仓位异常: ${e.message}", e)
|
||||
} finally {
|
||||
redeemCheckInProgress.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 逻辑2:处理未卖出订单
|
||||
* 检查所有未卖出的订单,匹配仓位
|
||||
|
||||
+16
-18
@@ -21,29 +21,29 @@ import java.util.concurrent.CopyOnWriteArrayList
|
||||
class PositionPollingService(
|
||||
private val accountService: AccountService
|
||||
) {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
|
||||
|
||||
|
||||
@Value("\${position.polling.interval:2000}")
|
||||
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
|
||||
|
||||
|
||||
// 订阅者列表(支持多个订阅者)
|
||||
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
|
||||
|
||||
|
||||
// 最新仓位数据(用于丢弃机制)
|
||||
@Volatile
|
||||
private var latestPositions: PositionListResponse? = null
|
||||
|
||||
|
||||
// 协程作用域和任务
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private var pollingJob: Job? = null
|
||||
|
||||
|
||||
// 事件分发协程(使用专门的线程,避免阻塞轮训)
|
||||
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
|
||||
// 同步锁,确保轮询任务的启动和停止是线程安全的
|
||||
private val lock = Any()
|
||||
|
||||
|
||||
/**
|
||||
* 初始化服务(后端启动时直接启动轮训)
|
||||
*/
|
||||
@@ -52,7 +52,7 @@ class PositionPollingService(
|
||||
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
|
||||
startPolling()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
@@ -66,7 +66,7 @@ class PositionPollingService(
|
||||
scope.cancel()
|
||||
eventDispatcherScope.cancel()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 订阅仓位事件
|
||||
* @param callback 回调函数,接收最新的仓位数据
|
||||
@@ -78,7 +78,7 @@ class PositionPollingService(
|
||||
latestPositions?.let { callback(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 取消订阅仓位事件
|
||||
*/
|
||||
@@ -87,7 +87,7 @@ class PositionPollingService(
|
||||
subscribers.remove(callback)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 启动轮训任务
|
||||
*/
|
||||
@@ -95,7 +95,7 @@ class PositionPollingService(
|
||||
synchronized(lock) {
|
||||
// 如果已经有轮训任务在运行,先取消
|
||||
pollingJob?.cancel()
|
||||
|
||||
|
||||
// 启动新的轮训任务
|
||||
pollingJob = scope.launch {
|
||||
while (isActive) {
|
||||
@@ -109,7 +109,7 @@ class PositionPollingService(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 轮训仓位数据并发布事件
|
||||
* 使用专门的线程分发事件,避免阻塞轮训
|
||||
@@ -123,7 +123,7 @@ class PositionPollingService(
|
||||
if (positions != null) {
|
||||
// 更新最新数据(丢弃旧数据,只保留最新的)
|
||||
latestPositions = positions
|
||||
|
||||
|
||||
// 在专门的线程中分发事件,避免阻塞轮训
|
||||
eventDispatcherScope.launch {
|
||||
try {
|
||||
@@ -131,7 +131,7 @@ class PositionPollingService(
|
||||
val currentSubscribers = synchronized(lock) {
|
||||
subscribers.toList() // 复制列表,避免并发修改
|
||||
}
|
||||
|
||||
|
||||
currentSubscribers.forEach { callback ->
|
||||
try {
|
||||
callback(positions)
|
||||
@@ -139,8 +139,6 @@ class PositionPollingService(
|
||||
logger.error("通知订阅者失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("发布仓位数据事件: currentPositions=${positions.currentPositions.size}, historyPositions=${positions.historyPositions.size}, subscribers=${currentSubscribers.size}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("分发仓位数据事件失败: ${e.message}", e)
|
||||
}
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.wrbug.polymarketbot.service.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* WCOL 解包轮询任务
|
||||
* 每 20 秒轮询一次,遍历所有账户的代理地址:若 WCOL 余额 > 0 则解包为 USDC.e。
|
||||
* 同一时间仅允许单次执行;若上次执行未结束则本次忽略(与现有轮询逻辑一致)。
|
||||
* 若未配置 Builder API Key,直接跳过本轮(解包依赖 Relayer Gasless,未配置则无法执行)。
|
||||
*/
|
||||
@Service
|
||||
class WcolUnwrapJobService(
|
||||
private val accountService: AccountService,
|
||||
private val relayClientService: RelayClientService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(WcolUnwrapJobService::class.java)
|
||||
private val scope = kotlinx.coroutines.CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private var unwrapJob: Job? = null
|
||||
|
||||
/**
|
||||
* 每 20 秒触发一次;若未配置 Builder Key 或当前任务仍在执行则跳过本次
|
||||
*/
|
||||
@Scheduled(fixedRate = 20_000)
|
||||
fun runWcolUnwrapPolling() {
|
||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||
logger.debug("Builder API Key 未配置,跳过 WCOL 解包轮询")
|
||||
return
|
||||
}
|
||||
if (unwrapJob?.isActive == true) {
|
||||
logger.debug("上一轮 WCOL 解包任务仍在执行,跳过本次")
|
||||
return
|
||||
}
|
||||
unwrapJob = scope.launch {
|
||||
try {
|
||||
accountService.runWcolUnwrapForAllAccounts()
|
||||
} catch (e: Exception) {
|
||||
logger.error("WCOL 解包轮询异常: ${e.message}", e)
|
||||
} finally {
|
||||
unwrapJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package com.wrbug.polymarketbot.service.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||
import com.wrbug.polymarketbot.dto.TradeData
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.delay
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 基于 start 游标的一批历史交易结果
|
||||
* @param trades 本批交易列表(已按时间升序)
|
||||
* @param nextCursorSeconds 下一页游标(API 的 start 参数,秒级);若本批不足 limit 条则为 null 表示最后一页
|
||||
*/
|
||||
data class LeaderTradesBatchResult(
|
||||
val trades: List<TradeData>,
|
||||
val nextCursorSeconds: Long?
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测数据服务
|
||||
* 直接从 Polymarket Data API 获取 Leader 历史交易,使用 start 游标分页(避免 offset 过大报错)
|
||||
*/
|
||||
@Service
|
||||
class BacktestDataService(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(BacktestDataService::class.java)
|
||||
|
||||
/**
|
||||
* 按 start 游标获取一批 Leader 历史交易
|
||||
* 规则:limit 固定为 500;若返回 500 条则取本批最大时间戳(秒)作为下一页 start,不加 1(同一秒可能多笔订单,由下游按 tradeId 去重);不足 500 则为最后一页
|
||||
*
|
||||
* @param leaderId Leader ID
|
||||
* @param startTime 回测开始时间(毫秒)
|
||||
* @param endTime 回测结束时间(毫秒)
|
||||
* @param cursorStartSeconds 本页游标(API 的 start,秒);首次传 startTime/1000
|
||||
* @param limit 每批条数,建议 500
|
||||
* @return 本批交易与下一页游标(null 表示没有下一页)
|
||||
*/
|
||||
suspend fun getLeaderHistoricalTradesBatch(
|
||||
leaderId: Long,
|
||||
startTime: Long,
|
||||
endTime: Long,
|
||||
cursorStartSeconds: Long,
|
||||
limit: Int
|
||||
): LeaderTradesBatchResult {
|
||||
logger.info("获取 Leader 历史交易批次: leaderId=$leaderId, cursorStart=$cursorStartSeconds, limit=$limit")
|
||||
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: throw IllegalArgumentException("Leader 不存在: $leaderId")
|
||||
|
||||
val dataApi = retrofitFactory.createDataApi()
|
||||
val endSeconds = endTime / 1000
|
||||
val maxRetries = 5
|
||||
val retryDelay = 1000L
|
||||
|
||||
var lastException: Exception? = null
|
||||
for (attempt in 1..maxRetries) {
|
||||
try {
|
||||
val response = dataApi.getUserActivity(
|
||||
user = leader.leaderAddress,
|
||||
type = listOf("TRADE"),
|
||||
start = cursorStartSeconds,
|
||||
end = endSeconds,
|
||||
limit = limit,
|
||||
offset = null,
|
||||
sortBy = "TIMESTAMP",
|
||||
sortDirection = "ASC"
|
||||
)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw Exception("从 Data API 获取用户活动失败: code=${response.code()}, message=${response.message()}")
|
||||
}
|
||||
|
||||
val activities = response.body()!!
|
||||
logger.info("本批获取 ${activities.size} 条活动(第 $attempt 次尝试)")
|
||||
|
||||
val trades = activities.mapNotNull { activity ->
|
||||
try {
|
||||
if (activity.type != "TRADE") return@mapNotNull null
|
||||
if (activity.side == null || activity.price == null || activity.size == null || activity.usdcSize == null) {
|
||||
logger.warn("活动数据缺少必要字段,跳过: activity=$activity")
|
||||
return@mapNotNull null
|
||||
}
|
||||
val tradeTimestamp = activity.timestamp * 1000
|
||||
if (tradeTimestamp < startTime || tradeTimestamp > endTime) {
|
||||
logger.debug("交易时间超出范围,跳过: timestamp=$tradeTimestamp")
|
||||
return@mapNotNull null
|
||||
}
|
||||
TradeData(
|
||||
tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}",
|
||||
marketId = activity.conditionId,
|
||||
marketTitle = activity.title,
|
||||
marketSlug = activity.slug,
|
||||
side = activity.side.uppercase(),
|
||||
outcome = activity.outcome ?: activity.outcomeIndex?.toString() ?: "",
|
||||
outcomeIndex = activity.outcomeIndex,
|
||||
price = activity.price.toSafeBigDecimal(),
|
||||
size = activity.size.toSafeBigDecimal(),
|
||||
amount = activity.usdcSize.toSafeBigDecimal(),
|
||||
timestamp = tradeTimestamp
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("转换活动数据失败: activity=$activity, error=${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// 下一页 start 用本批最大 timestamp(秒),不加 1:同一秒可能有多笔订单,依赖下游按 tradeId 去重
|
||||
val nextCursorSeconds: Long? = if (trades.size < limit) {
|
||||
null
|
||||
} else {
|
||||
val maxTs = trades.maxOf { it.timestamp }
|
||||
maxTs / 1000
|
||||
}
|
||||
return LeaderTradesBatchResult(trades = trades, nextCursorSeconds = nextCursorSeconds)
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
logger.warn("第 $attempt/$maxRetries 次获取批次失败: ${e.message}")
|
||||
if (attempt < maxRetries) {
|
||||
logger.info("等待 $retryDelay 毫秒后重试...")
|
||||
delay(retryDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
val errorMsg = "重试 $maxRetries 次后仍然失败,cursorStart=$cursorStartSeconds"
|
||||
logger.error(errorMsg, lastException)
|
||||
throw Exception(errorMsg, lastException)
|
||||
}
|
||||
}
|
||||
+809
@@ -0,0 +1,809 @@
|
||||
package com.wrbug.polymarketbot.service.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.dto.TradeData
|
||||
import com.wrbug.polymarketbot.dto.BacktestStatisticsDto
|
||||
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||
import com.wrbug.polymarketbot.entity.BacktestTrade
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
|
||||
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||
import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Service
|
||||
class BacktestExecutionService(
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val backtestTradeRepository: BacktestTradeRepository,
|
||||
private val backtestDataService: BacktestDataService,
|
||||
private val marketPriceService: MarketPriceService,
|
||||
private val marketService: MarketService,
|
||||
private val copyTradingFilterService: CopyTradingFilterService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(BacktestExecutionService::class.java)
|
||||
|
||||
/**
|
||||
* 持仓数据结构
|
||||
* @param marketEndDate 市场结束时间(毫秒),用于到期结算判断,null 表示未知
|
||||
*/
|
||||
data class Position(
|
||||
val marketId: String,
|
||||
val outcome: String,
|
||||
val outcomeIndex: Int?,
|
||||
var quantity: BigDecimal,
|
||||
val avgPrice: BigDecimal,
|
||||
val leaderBuyQuantity: BigDecimal?,
|
||||
val marketEndDate: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 将回测任务转换为虚拟的 CopyTrading 配置用于执行
|
||||
* 注意:回测场景使用历史数据,不需要实时跟单的相关配置
|
||||
*/
|
||||
private fun taskToCopyTrading(task: BacktestTask): CopyTrading {
|
||||
return CopyTrading(
|
||||
id = task.id,
|
||||
accountId = 0L,
|
||||
leaderId = task.leaderId,
|
||||
enabled = true,
|
||||
copyMode = task.copyMode,
|
||||
copyRatio = task.copyRatio,
|
||||
fixedAmount = null,
|
||||
maxOrderSize = task.maxOrderSize,
|
||||
minOrderSize = task.minOrderSize,
|
||||
maxDailyLoss = task.maxDailyLoss,
|
||||
maxDailyOrders = task.maxDailyOrders,
|
||||
priceTolerance = BigDecimal.ZERO, // 回测使用历史价格,不需要容忍度
|
||||
delaySeconds = 0, // 回测按时间线执行,无需延迟
|
||||
pollIntervalSeconds = 5,
|
||||
useWebSocket = false,
|
||||
websocketReconnectInterval = 5000,
|
||||
websocketMaxRetries = 10,
|
||||
supportSell = task.supportSell,
|
||||
minOrderDepth = null, // 回测无实时订单簿数据
|
||||
maxSpread = null, // 回测无实时价差数据
|
||||
maxPositionValue = task.maxPositionValue,
|
||||
minPrice = task.minPrice, // 最低价格
|
||||
maxPrice = task.maxPrice, // 最高价格
|
||||
keywordFilterMode = task.keywordFilterMode,
|
||||
keywords = task.keywords,
|
||||
configName = null,
|
||||
pushFailedOrders = false,
|
||||
pushFilteredOrders = false,
|
||||
createdAt = task.createdAt,
|
||||
updatedAt = task.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行回测任务(支持分页和恢复)
|
||||
* 自动处理所有页面的数据,支持中断恢复
|
||||
*/
|
||||
/** 每批请求 API 的条数(基于 start 游标分页,避免 offset 过大) */
|
||||
private val backtestBatchLimit = 500
|
||||
|
||||
@Transactional
|
||||
suspend fun executeBacktest(task: BacktestTask, page: Int = 1, size: Int = 100) {
|
||||
try {
|
||||
logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}, batchLimit=$backtestBatchLimit")
|
||||
|
||||
// 1. 更新任务状态为 RUNNING
|
||||
task.status = "RUNNING"
|
||||
task.executionStartedAt = System.currentTimeMillis()
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
// 2. 初始化
|
||||
var currentBalance = task.initialBalance
|
||||
val positions = mutableMapOf<String, Position>()
|
||||
val trades = mutableListOf<BacktestTrade>()
|
||||
val dailyOrderCountCache = mutableMapOf<String, Int>()
|
||||
val dailyLossCache = mutableMapOf<String, BigDecimal>()
|
||||
val seenTradeIds = mutableSetOf<String>()
|
||||
|
||||
// 3. 回测时间范围:首次执行以当前时间为基准取最近 backtestDays 天;断点续跑保留原 startTime,仅 endTime 延到当前
|
||||
val endTime = System.currentTimeMillis()
|
||||
val startTime = if (task.lastProcessedTradeTime == null) {
|
||||
endTime - (task.backtestDays * 24L * 3600 * 1000)
|
||||
} else {
|
||||
task.startTime
|
||||
}
|
||||
|
||||
logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)} (${task.backtestDays} 天), " +
|
||||
"初始余额: ${task.initialBalance.toPlainString()}")
|
||||
|
||||
// 4. 游标分页:恢复时也从 lastProcessedTradeTime 所在秒开始拉(不加 1),与分页规则一致;已处理的通过 timestamp 跳过,不依赖内存 seenTradeIds
|
||||
var cursorSeconds = if (task.lastProcessedTradeTime != null) {
|
||||
task.lastProcessedTradeTime!! / 1000
|
||||
} else {
|
||||
startTime / 1000
|
||||
}
|
||||
val endSeconds = endTime / 1000
|
||||
val resumeThresholdMs = task.lastProcessedTradeTime ?: 0L
|
||||
|
||||
logger.info("开始游标分页:cursorStart=$cursorSeconds(恢复则跳过 timestamp<=${resumeThresholdMs}ms)")
|
||||
|
||||
var terminateBacktest = false
|
||||
while (true) {
|
||||
if (terminateBacktest) {
|
||||
logger.info("余额已为负或不足,终止回测循环")
|
||||
break
|
||||
}
|
||||
val currentTaskStatus = backtestTaskRepository.findById(task.id!!).orElse(null)
|
||||
if (currentTaskStatus == null || currentTaskStatus.status != "RUNNING") {
|
||||
logger.info("回测任务状态已变更: ${currentTaskStatus?.status},停止执行")
|
||||
break
|
||||
}
|
||||
|
||||
logger.info("正在获取批次数据 cursorStart=$cursorSeconds (${formatTimestamp(cursorSeconds * 1000)}) ...")
|
||||
|
||||
val currentPageTrades = mutableListOf<BacktestTrade>()
|
||||
|
||||
try {
|
||||
val batch = backtestDataService.getLeaderHistoricalTradesBatch(
|
||||
task.leaderId,
|
||||
startTime,
|
||||
endTime,
|
||||
cursorSeconds,
|
||||
backtestBatchLimit
|
||||
)
|
||||
val pageTrades = batch.trades
|
||||
|
||||
if (pageTrades.isEmpty()) {
|
||||
logger.info("本批无数据,所有数据处理完成")
|
||||
break
|
||||
}
|
||||
|
||||
logger.info("本批获取 ${pageTrades.size} 条交易,是否有下一页: ${batch.nextCursorSeconds != null}")
|
||||
|
||||
val countAtBatchStart = task.processedTradeCount
|
||||
var lastProcessedIndexInPage: Int? = null
|
||||
var processedInBatch = 0
|
||||
for (localIndex in pageTrades.indices) {
|
||||
val leaderTrade = pageTrades[localIndex]
|
||||
if (leaderTrade.tradeId in seenTradeIds) {
|
||||
logger.debug("跳过重复交易: ${leaderTrade.tradeId}")
|
||||
continue
|
||||
}
|
||||
if (resumeThresholdMs > 0 && leaderTrade.timestamp <= resumeThresholdMs) {
|
||||
logger.debug("恢复时跳过已处理时间戳: tradeId=${leaderTrade.tradeId}, timestamp=${leaderTrade.timestamp}")
|
||||
continue
|
||||
}
|
||||
seenTradeIds.add(leaderTrade.tradeId)
|
||||
|
||||
val index = countAtBatchStart + processedInBatch
|
||||
lastProcessedIndexInPage = index
|
||||
processedInBatch++
|
||||
|
||||
// 进度按时间比例:(当前订单时间 - 开始时间) / (结束时间 - 开始时间) * 100,运行中上限 99
|
||||
val timeRange = endTime - startTime
|
||||
val progress = if (timeRange > 0) {
|
||||
val elapsed = (leaderTrade.timestamp - startTime).coerceIn(0L, timeRange)
|
||||
min(99, ((elapsed * 100) / timeRange).toInt())
|
||||
} else {
|
||||
0
|
||||
}
|
||||
if (progress > task.progress) {
|
||||
task.progress = progress
|
||||
task.processedTradeCount = index + 1
|
||||
backtestTaskRepository.save(task)
|
||||
}
|
||||
|
||||
try {
|
||||
// 5.1 实时检查并结算已到期的市场
|
||||
currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp, currentPageTrades)
|
||||
|
||||
// 5.2 检查余额和持仓状态
|
||||
if (currentBalance <= BigDecimal.ONE) {
|
||||
logger.info(
|
||||
if (currentBalance < BigDecimal.ZERO) "余额已为负,直接终止回测: $currentBalance"
|
||||
else "余额<=1,停止回测: $currentBalance"
|
||||
)
|
||||
terminateBacktest = true
|
||||
break
|
||||
}
|
||||
|
||||
// 5.3 应用过滤规则
|
||||
val copyTrading = taskToCopyTrading(task)
|
||||
val filterResult = copyTradingFilterService.checkFilters(
|
||||
copyTrading,
|
||||
tokenId = "",
|
||||
tradePrice = leaderTrade.price,
|
||||
copyOrderAmount = null,
|
||||
marketId = leaderTrade.marketId,
|
||||
marketTitle = leaderTrade.marketTitle,
|
||||
marketEndDate = null,
|
||||
outcomeIndex = leaderTrade.outcomeIndex
|
||||
)
|
||||
|
||||
if (!filterResult.isPassed) {
|
||||
logger.debug("交易被过滤: ${leaderTrade.tradeId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 5.4 每日订单数检查 - 使用缓存,只统计 BUY 订单
|
||||
val tradeDate = formatDate(leaderTrade.timestamp)
|
||||
val dailyOrderCount = dailyOrderCountCache.getOrDefault(tradeDate, 0)
|
||||
|
||||
if (dailyOrderCount >= task.maxDailyOrders) {
|
||||
logger.info("已达到每日最大 BUY 订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// 5.6 计算跟单金额
|
||||
val followAmount = calculateFollowAmount(task, leaderTrade)
|
||||
|
||||
// 5.6.1 检查订单大小限制
|
||||
val finalFollowAmount = if (followAmount > task.maxOrderSize) {
|
||||
logger.info("跟单金额超过最大限制: $followAmount > ${task.maxOrderSize},调整为最大值")
|
||||
task.maxOrderSize
|
||||
} else if (followAmount < task.minOrderSize) {
|
||||
logger.info("跟单金额低于最小限制: $followAmount < ${task.minOrderSize},调整为最小值")
|
||||
task.minOrderSize
|
||||
} else {
|
||||
followAmount
|
||||
}
|
||||
|
||||
// 5.6.2 检查每日最大亏损(买入订单)- 使用缓存
|
||||
val dailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
|
||||
if (dailyLoss > task.maxDailyLoss) {
|
||||
logger.info("已达到每日最大亏损限制: $dailyLoss / ${task.maxDailyLoss},跳过买入订单")
|
||||
continue
|
||||
}
|
||||
|
||||
// 5.7 处理买卖逻辑
|
||||
if (leaderTrade.side == "BUY") {
|
||||
// 余额不足时按最大可用余额交易,仍须满足最小订单金额
|
||||
val actualBuyAmount = if (currentBalance < finalFollowAmount) {
|
||||
logger.debug("余额不足,按最大余额买入: balance=$currentBalance, 原需=$finalFollowAmount, marketId=${leaderTrade.marketId}")
|
||||
currentBalance
|
||||
} else {
|
||||
finalFollowAmount
|
||||
}
|
||||
if (actualBuyAmount < task.minOrderSize) {
|
||||
logger.debug("可用金额低于最小订单限制跳过: actual=$actualBuyAmount, minOrderSize=${task.minOrderSize}")
|
||||
continue
|
||||
}
|
||||
val quantity = actualBuyAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN)
|
||||
if (quantity <= BigDecimal.ZERO) {
|
||||
logger.debug("计算数量为0跳过: actualBuyAmount=$actualBuyAmount, price=${leaderTrade.price}")
|
||||
continue
|
||||
}
|
||||
val totalCost = actualBuyAmount
|
||||
|
||||
// 5.6.3 检查最大仓位限制(如果配置了)
|
||||
if (task.maxPositionValue != null) {
|
||||
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
|
||||
val currentPosition = positions[positionKey]
|
||||
val currentPositionValue = if (currentPosition != null) {
|
||||
currentPosition.quantity.multiply(currentPosition.avgPrice)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
val totalValueAfterOrder = currentPositionValue.add(actualBuyAmount)
|
||||
|
||||
if (totalValueAfterOrder.gt(task.maxPositionValue)) {
|
||||
val currentPositionValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
|
||||
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
|
||||
val maxValueStr = task.maxPositionValue.stripTrailingZeros().toPlainString()
|
||||
logger.info("超过最大仓位金额限制: 市场=${leaderTrade.marketId}, 方向=${leaderTrade.outcomeIndex}, 当前仓位=${currentPositionValueStr} USDC, 买入金额=${actualBuyAmount} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 更新余额和持仓(同市场同 outcome 多次买入合并:数量相加、加权均价、leaderBuyQuantity 相加)
|
||||
currentBalance -= totalCost
|
||||
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
|
||||
val price = leaderTrade.price.toSafeBigDecimal()
|
||||
val leaderSize = leaderTrade.size.toSafeBigDecimal()
|
||||
val existing = positions[positionKey]
|
||||
positions[positionKey] = if (existing != null) {
|
||||
val newQuantity = existing.quantity.add(quantity)
|
||||
val newAvgPrice = if (newQuantity > BigDecimal.ZERO) {
|
||||
existing.quantity.multiply(existing.avgPrice).add(quantity.multiply(price))
|
||||
.divide(newQuantity, 8, java.math.RoundingMode.HALF_UP)
|
||||
} else {
|
||||
price
|
||||
}
|
||||
val newLeaderBuyQuantity = (existing.leaderBuyQuantity ?: BigDecimal.ZERO).add(leaderSize)
|
||||
Position(
|
||||
marketId = leaderTrade.marketId,
|
||||
outcome = leaderTrade.outcome ?: "",
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = newQuantity,
|
||||
avgPrice = newAvgPrice,
|
||||
leaderBuyQuantity = newLeaderBuyQuantity,
|
||||
marketEndDate = existing.marketEndDate
|
||||
)
|
||||
} else {
|
||||
val market = marketService.getMarket(leaderTrade.marketId)
|
||||
Position(
|
||||
marketId = leaderTrade.marketId,
|
||||
outcome = leaderTrade.outcome ?: "",
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = quantity,
|
||||
avgPrice = price,
|
||||
leaderBuyQuantity = leaderSize,
|
||||
marketEndDate = market?.endDate
|
||||
)
|
||||
}
|
||||
|
||||
// 记录交易到当前页列表
|
||||
currentPageTrades.add(BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = leaderTrade.timestamp,
|
||||
marketId = leaderTrade.marketId,
|
||||
marketTitle = leaderTrade.marketTitle,
|
||||
side = "BUY",
|
||||
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = quantity,
|
||||
price = leaderTrade.price.toSafeBigDecimal(),
|
||||
amount = actualBuyAmount,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = null,
|
||||
balanceAfter = currentBalance,
|
||||
leaderTradeId = leaderTrade.tradeId
|
||||
))
|
||||
|
||||
// 更新每日订单数缓存
|
||||
dailyOrderCountCache[tradeDate] = dailyOrderCount + 1
|
||||
|
||||
} else {
|
||||
// SELL 逻辑
|
||||
if (!task.supportSell) {
|
||||
continue
|
||||
}
|
||||
|
||||
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
|
||||
val position = positions[positionKey] ?: continue
|
||||
|
||||
// 计算卖出数量
|
||||
val sellQuantity = if (task.copyMode == "RATIO") {
|
||||
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
|
||||
position.quantity.multiply(
|
||||
leaderTrade.size.divide(position.leaderBuyQuantity, 8, java.math.RoundingMode.DOWN)
|
||||
)
|
||||
} else {
|
||||
position.quantity
|
||||
}
|
||||
} else {
|
||||
position.quantity
|
||||
}
|
||||
|
||||
val actualSellQuantity = if (sellQuantity > position.quantity) {
|
||||
position.quantity
|
||||
} else {
|
||||
sellQuantity
|
||||
}
|
||||
|
||||
// 计算卖出金额
|
||||
val sellAmount = actualSellQuantity.multiply(leaderTrade.price.toSafeBigDecimal())
|
||||
|
||||
// 5.6.2 检查卖出金额限制
|
||||
val finalSellAmount = if (sellAmount > task.maxOrderSize) {
|
||||
logger.info("卖出金额超过最大限制: $sellAmount > ${task.maxOrderSize},调整为最大值")
|
||||
task.maxOrderSize
|
||||
} else if (sellAmount < task.minOrderSize) {
|
||||
logger.info("卖出金额低于最小限制: $sellAmount < ${task.minOrderSize},调整为最小值")
|
||||
task.minOrderSize
|
||||
} else {
|
||||
sellAmount
|
||||
}
|
||||
|
||||
val netAmount = finalSellAmount
|
||||
|
||||
// 计算盈亏
|
||||
val cost = actualSellQuantity.multiply(position.avgPrice)
|
||||
val profitLoss = netAmount.subtract(cost)
|
||||
|
||||
// Bug #39 Fix: correctly reduce position quantity after sell
|
||||
currentBalance += netAmount
|
||||
val remainingQuantity = position.quantity - actualSellQuantity
|
||||
val remainingLeaderBuyQuantity = if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
|
||||
val totalQty = position.quantity
|
||||
val leaderReduction = actualSellQuantity.divide(
|
||||
totalQty, 8, java.math.RoundingMode.DOWN
|
||||
)
|
||||
(position.leaderBuyQuantity - leaderReduction).coerceAtLeast(BigDecimal.ZERO)
|
||||
} else {
|
||||
position.leaderBuyQuantity
|
||||
}
|
||||
if (remainingQuantity <= BigDecimal.ZERO) {
|
||||
positions.remove(positionKey)
|
||||
} else {
|
||||
positions[positionKey] = position.copy(
|
||||
quantity = remainingQuantity,
|
||||
leaderBuyQuantity = remainingLeaderBuyQuantity
|
||||
)
|
||||
}
|
||||
|
||||
// 记录交易到当前页列表
|
||||
currentPageTrades.add(BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = leaderTrade.timestamp,
|
||||
marketId = leaderTrade.marketId,
|
||||
marketTitle = leaderTrade.marketTitle,
|
||||
side = "SELL",
|
||||
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = actualSellQuantity,
|
||||
price = leaderTrade.price.toSafeBigDecimal(),
|
||||
amount = finalSellAmount,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = profitLoss,
|
||||
balanceAfter = currentBalance,
|
||||
leaderTradeId = leaderTrade.tradeId
|
||||
))
|
||||
// SELL 订单不计入每日订单数限制
|
||||
|
||||
// 更新每日亏损缓存(只累加亏损,不累加盈利)
|
||||
if (profitLoss < BigDecimal.ZERO) {
|
||||
val currentDailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
|
||||
dailyLossCache[tradeDate] = currentDailyLoss + profitLoss.negate()
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易失败: tradeId=${leaderTrade.tradeId}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存本批交易
|
||||
if (currentPageTrades.isNotEmpty()) {
|
||||
logger.info("保存本批交易,共 ${currentPageTrades.size} 笔")
|
||||
backtestTradeRepository.saveAll(currentPageTrades)
|
||||
|
||||
val lastTradeInPage = currentPageTrades.lastOrNull()
|
||||
if (lastTradeInPage != null && lastProcessedIndexInPage != null) {
|
||||
task.lastProcessedTradeTime = lastTradeInPage.tradeTime
|
||||
task.lastProcessedTradeIndex = lastProcessedIndexInPage
|
||||
task.processedTradeCount = lastProcessedIndexInPage + 1
|
||||
task.finalBalance = currentBalance
|
||||
backtestTaskRepository.save(task)
|
||||
logger.info("本批处理完成,lastProcessedTradeIndex=${task.lastProcessedTradeIndex}, 总处理数=${task.processedTradeCount}")
|
||||
}
|
||||
} else {
|
||||
logger.info("本批没有交易需要保存")
|
||||
}
|
||||
|
||||
trades.addAll(currentPageTrades)
|
||||
|
||||
if (batch.nextCursorSeconds == null) {
|
||||
logger.info("本批不足 $backtestBatchLimit 条,已是最后一页")
|
||||
break
|
||||
}
|
||||
cursorSeconds = batch.nextCursorSeconds!!
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取或处理本批数据失败: ${e.message}", e)
|
||||
// 重试失败,标记任务为 FAILED
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 处理回测结束时仍未到期的持仓
|
||||
val remainingSettlements = mutableListOf<BacktestTrade>()
|
||||
currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime, remainingSettlements)
|
||||
if (remainingSettlements.isNotEmpty()) {
|
||||
backtestTradeRepository.saveAll(remainingSettlements)
|
||||
logger.info("回测结束结算剩余持仓,持久化 ${remainingSettlements.size} 笔 SETTLEMENT(CLOSED)")
|
||||
}
|
||||
|
||||
// 7. 计算最终统计数据
|
||||
val statistics = calculateStatistics(trades)
|
||||
|
||||
// 8. 更新任务状态
|
||||
val profitAmount = currentBalance.subtract(task.initialBalance)
|
||||
val profitRate = if (task.initialBalance > BigDecimal.ZERO) {
|
||||
profitAmount.divide(task.initialBalance, 4, java.math.RoundingMode.HALF_UP).multiply(BigDecimal("100"))
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
val finalStatus = if (task.status == "STOPPED") "STOPPED" else "COMPLETED"
|
||||
|
||||
task.finalBalance = currentBalance
|
||||
task.profitAmount = profitAmount
|
||||
task.profitRate = profitRate
|
||||
task.endTime = endTime
|
||||
task.status = finalStatus
|
||||
task.progress = 100
|
||||
task.totalTrades = trades.size
|
||||
task.buyTrades = trades.count { it.side == "BUY" }
|
||||
task.sellTrades = trades.count { it.side == "SELL" }
|
||||
task.winTrades = statistics.winTrades
|
||||
task.lossTrades = statistics.lossTrades
|
||||
task.winRate = statistics.winRate.toSafeBigDecimal()
|
||||
task.maxProfit = statistics.maxProfit.toSafeBigDecimal()
|
||||
task.maxLoss = statistics.maxLoss.toSafeBigDecimal()
|
||||
task.maxDrawdown = statistics.maxDrawdown.toSafeBigDecimal()
|
||||
task.avgHoldingTime = statistics.avgHoldingTime
|
||||
task.executionFinishedAt = System.currentTimeMillis()
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
logger.info("回测任务执行完成: taskId=${task.id}, " +
|
||||
"最终余额=${currentBalance.toPlainString()}, " +
|
||||
"收益额=${task.profitAmount?.toPlainString()}, " +
|
||||
"收益率=${task.profitRate?.toPlainString()}%, " +
|
||||
"总交易数=${trades.size}, " +
|
||||
"盈利率=${task.winRate?.toPlainString()}%")
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("回测任务执行失败: taskId=${task.id}", e)
|
||||
task.status = "FAILED"
|
||||
task.errorMessage = e.message
|
||||
task.executionFinishedAt = System.currentTimeMillis()
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
backtestTaskRepository.save(task)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算已到期的市场
|
||||
* @param batchTradesToSave 本批要持久化的交易列表,到期结算(赎回/输)会追加到此列表并随本批一起落库
|
||||
*/
|
||||
private suspend fun settleExpiredPositions(
|
||||
task: BacktestTask,
|
||||
positions: MutableMap<String, Position>,
|
||||
currentBalance: BigDecimal,
|
||||
trades: MutableList<BacktestTrade>,
|
||||
currentTime: Long,
|
||||
batchTradesToSave: MutableList<BacktestTrade>
|
||||
): BigDecimal {
|
||||
var balance = currentBalance
|
||||
|
||||
for ((positionKey, position) in positions.toList()) {
|
||||
try {
|
||||
// 仅当市场已到期(结束时间 <= 当前回测时间)时才结算,避免未到期持仓被误结算
|
||||
if (position.marketEndDate == null || position.marketEndDate!! > currentTime) {
|
||||
logger.debug("持仓未到期跳过结算: marketId=${position.marketId}, endDate=${position.marketEndDate}, currentTime=$currentTime")
|
||||
continue
|
||||
}
|
||||
// 获取市场当前价格
|
||||
val marketPrice = marketPriceService.getCurrentMarketPrice(
|
||||
position.marketId,
|
||||
position.outcomeIndex ?: 0
|
||||
)
|
||||
|
||||
val price = marketPrice.toSafeBigDecimal()
|
||||
|
||||
// 通过市场价格判断结算价格
|
||||
val settlementPrice = when {
|
||||
price >= BigDecimal("0.95") -> BigDecimal.ONE
|
||||
price <= BigDecimal("0.05") -> BigDecimal.ZERO
|
||||
else -> position.avgPrice
|
||||
}
|
||||
|
||||
val settlementValue = position.quantity.multiply(settlementPrice)
|
||||
val profitLoss = settlementValue.subtract(position.quantity.multiply(position.avgPrice))
|
||||
|
||||
balance += settlementValue
|
||||
|
||||
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
|
||||
val settlementTrade = BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = currentTime,
|
||||
marketId = position.marketId,
|
||||
marketTitle = marketTitle,
|
||||
side = "SETTLEMENT",
|
||||
outcome = when {
|
||||
settlementPrice == BigDecimal.ONE -> "WIN"
|
||||
settlementPrice == BigDecimal.ZERO -> "LOSE"
|
||||
else -> "UNKNOWN"
|
||||
},
|
||||
outcomeIndex = position.outcomeIndex,
|
||||
quantity = position.quantity,
|
||||
price = settlementPrice,
|
||||
amount = settlementValue,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = profitLoss,
|
||||
balanceAfter = balance,
|
||||
leaderTradeId = null
|
||||
)
|
||||
trades.add(settlementTrade)
|
||||
batchTradesToSave.add(settlementTrade)
|
||||
|
||||
// 移除已结算的持仓
|
||||
positions.remove(positionKey)
|
||||
} catch (e: Exception) {
|
||||
logger.error("结算市场失败: marketId=${position.marketId}, outcomeIndex=${position.outcomeIndex}", e)
|
||||
}
|
||||
}
|
||||
|
||||
return balance
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算未到期持仓(回测结束时剩余持仓按均价平仓)
|
||||
* @param settlementsToSave 本批结算记录会追加到此列表,调用方需落库
|
||||
*/
|
||||
private suspend fun settleRemainingPositions(
|
||||
task: BacktestTask,
|
||||
positions: MutableMap<String, Position>,
|
||||
currentBalance: BigDecimal,
|
||||
trades: MutableList<BacktestTrade>,
|
||||
currentTime: Long,
|
||||
settlementsToSave: MutableList<BacktestTrade>
|
||||
): BigDecimal {
|
||||
var balance = currentBalance
|
||||
|
||||
for ((positionKey, position) in positions.toList()) {
|
||||
val quantity = position.quantity
|
||||
val avgPrice = position.avgPrice
|
||||
val settlementPrice = avgPrice
|
||||
|
||||
val settlementValue = quantity.multiply(settlementPrice)
|
||||
// Bug #39 Fix: profitLoss for closed settlement at avgPrice should be ~0
|
||||
val profitLoss = settlementValue.subtract(quantity.multiply(avgPrice))
|
||||
|
||||
balance += settlementValue
|
||||
|
||||
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
|
||||
val closedTrade = BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = currentTime,
|
||||
marketId = position.marketId,
|
||||
marketTitle = marketTitle,
|
||||
side = "SETTLEMENT",
|
||||
outcome = "CLOSED",
|
||||
outcomeIndex = position.outcomeIndex,
|
||||
quantity = quantity,
|
||||
price = avgPrice,
|
||||
amount = settlementValue,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = profitLoss,
|
||||
balanceAfter = balance,
|
||||
leaderTradeId = null
|
||||
)
|
||||
trades.add(closedTrade)
|
||||
settlementsToSave.add(closedTrade)
|
||||
}
|
||||
|
||||
positions.clear()
|
||||
return balance
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算统计数据
|
||||
*/
|
||||
private fun calculateStatistics(trades: List<BacktestTrade>): BacktestStatisticsDto {
|
||||
val buyTrades = trades.count { it.side == "BUY" }
|
||||
val sellTrades = trades.count { it.side == "SELL" }
|
||||
val winTrades = trades.count { it.profitLoss != null && it.profitLoss > BigDecimal.ZERO }
|
||||
val lossTrades = trades.count { it.profitLoss != null && it.profitLoss < BigDecimal.ZERO }
|
||||
|
||||
var totalProfit = BigDecimal.ZERO
|
||||
var totalLoss = BigDecimal.ZERO
|
||||
var maxProfit = BigDecimal.ZERO
|
||||
var maxLoss = BigDecimal.ZERO
|
||||
|
||||
// 计算最大回撤
|
||||
var runningBalance = if (trades.isNotEmpty()) {
|
||||
trades[0].balanceAfter?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
var peakBalance = runningBalance
|
||||
var maxDrawdown = BigDecimal.ZERO
|
||||
|
||||
for (i in trades.indices) {
|
||||
val trade = trades[i]
|
||||
val balance = trade.balanceAfter?.toSafeBigDecimal() ?: continue
|
||||
|
||||
if (trade.profitLoss != null) {
|
||||
val pnl = trade.profitLoss.toSafeBigDecimal()
|
||||
if (pnl > BigDecimal.ZERO) {
|
||||
totalProfit += pnl
|
||||
if (pnl > maxProfit) maxProfit = pnl
|
||||
} else {
|
||||
totalLoss += pnl
|
||||
if (pnl < maxLoss) maxLoss = pnl
|
||||
}
|
||||
}
|
||||
|
||||
if (balance > peakBalance) {
|
||||
peakBalance = balance
|
||||
}
|
||||
// Bug #39 Fix: use current balance, not runningBalance from previous iteration
|
||||
val drawdown = peakBalance - balance
|
||||
if (drawdown > maxDrawdown) {
|
||||
maxDrawdown = drawdown
|
||||
}
|
||||
|
||||
runningBalance = balance
|
||||
}
|
||||
|
||||
// 计算平均持仓时间
|
||||
var avgHoldingTime: Long? = null
|
||||
if (trades.size > 1) {
|
||||
var totalHoldingTime = 0L
|
||||
var count = 0
|
||||
for (i in 0 until trades.size - 1) {
|
||||
val currentTrade = trades[i]
|
||||
val nextTrade = trades[i + 1]
|
||||
|
||||
if (currentTrade.side == "BUY" && nextTrade.side == "SELL") {
|
||||
val holdingTime = nextTrade.tradeTime - currentTrade.tradeTime
|
||||
totalHoldingTime += holdingTime
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
avgHoldingTime = totalHoldingTime / count
|
||||
}
|
||||
}
|
||||
|
||||
return BacktestStatisticsDto(
|
||||
totalTrades = trades.size,
|
||||
buyTrades = buyTrades,
|
||||
sellTrades = sellTrades,
|
||||
winTrades = winTrades,
|
||||
lossTrades = lossTrades,
|
||||
winRate = if (buyTrades + sellTrades > 0) {
|
||||
(winTrades.toBigDecimal().divide((buyTrades + sellTrades).toBigDecimal(), 4, java.math.RoundingMode.HALF_UP))
|
||||
.multiply(BigDecimal("100"))
|
||||
.toPlainString()
|
||||
} else {
|
||||
BigDecimal.ZERO.toPlainString()
|
||||
},
|
||||
maxProfit = maxProfit.toPlainString(),
|
||||
maxLoss = maxLoss.toPlainString(),
|
||||
maxDrawdown = maxDrawdown.toPlainString(),
|
||||
avgHoldingTime = avgHoldingTime
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算跟单金额
|
||||
*/
|
||||
private fun calculateFollowAmount(task: BacktestTask, leaderTrade: TradeData): BigDecimal {
|
||||
return if (task.copyMode == "RATIO") {
|
||||
// 比例模式:Leader 成交金额 × 跟单比例
|
||||
leaderTrade.amount.toSafeBigDecimal().multiply(task.copyRatio)
|
||||
} else {
|
||||
// 固定金额模式:使用配置的固定金额
|
||||
task.fixedAmount ?: leaderTrade.amount.toSafeBigDecimal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否同一天
|
||||
*/
|
||||
private fun isSameDay(timestamp1: Long, timestamp2: Long): Boolean {
|
||||
val cal1 = Calendar.getInstance().apply { timeInMillis = timestamp1 }
|
||||
val cal2 = Calendar.getInstance().apply { timeInMillis = timestamp2 }
|
||||
return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
|
||||
cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳
|
||||
*/
|
||||
private fun formatTimestamp(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期(用于缓存key)
|
||||
*/
|
||||
private fun formatDate(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd")
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package com.wrbug.polymarketbot.service.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* 回测轮询服务
|
||||
* 定时获取待执行的回测任务并执行
|
||||
*/
|
||||
@Service
|
||||
class BacktestPollingService(
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val executionService: BacktestExecutionService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(BacktestPollingService::class.java)
|
||||
|
||||
// 线程池:同一时刻只执行一个任务
|
||||
private val executor: ExecutorService = Executors.newFixedThreadPool(1) as ThreadPoolExecutor
|
||||
|
||||
/**
|
||||
* 轮询待执行的回测任务
|
||||
* 每 10 秒执行一次
|
||||
* 规则:同一时刻只执行一个任务,如果有多个待执行任务,按创建时间先后执行最早创建的
|
||||
*/
|
||||
@Scheduled(fixedDelay = 10000) // 10 秒
|
||||
fun pollPendingTasks() {
|
||||
try {
|
||||
// 1. 检查是否有长时间处于 RUNNING 状态的任务(可能是应用重启导致的)
|
||||
val runningTasks = backtestTaskRepository.findByStatus("RUNNING")
|
||||
if (runningTasks.isNotEmpty()) {
|
||||
val activeQueueSize = (executor as ThreadPoolExecutor).queue.size
|
||||
val activeCount = (executor as ThreadPoolExecutor).activeCount
|
||||
|
||||
// 如果有线程池中没有活跃任务但有 RUNNING 状态的任务,说明是应用重启导致的
|
||||
// 重置这些任务的状态为 PENDING,以便恢复执行
|
||||
if (activeCount == 0 && runningTasks.isNotEmpty()) {
|
||||
logger.info("检测到应用重启导致的异常 RUNNING 任务,重置为 PENDING 以便恢复")
|
||||
runningTasks.forEach { task ->
|
||||
val now = System.currentTimeMillis()
|
||||
val executionStartedAt = task.executionStartedAt
|
||||
val executionDuration = if (executionStartedAt != null) {
|
||||
now - executionStartedAt
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
|
||||
// 如果任务执行时间超过 1 分钟,认为是异常状态
|
||||
if (executionDuration > 60000) {
|
||||
logger.info("重置异常 RUNNING 任务: taskId=${task.id}, executionStartedAt=$executionStartedAt, duration=${executionDuration}ms")
|
||||
task.status = "PENDING"
|
||||
task.updatedAt = now
|
||||
backtestTaskRepository.save(task)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug("有 ${runningTasks.size} 个任务正在执行,跳过本次轮询")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查询所有 PENDING 状态的任务,按创建时间升序排序
|
||||
val pendingTasks = backtestTaskRepository.findByStatus("PENDING")
|
||||
.sortedBy { it.createdAt }
|
||||
|
||||
if (pendingTasks.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 只执行最早创建的任务
|
||||
val taskToExecute = pendingTasks.first()
|
||||
logger.info("找到 ${pendingTasks.size} 个待执行的回测任务,执行最早创建的任务: taskId=${taskToExecute.id}, createdAt=${taskToExecute.createdAt}")
|
||||
|
||||
// 4. 提交任务到线程池执行
|
||||
executor.submit {
|
||||
try {
|
||||
// 执行前再次检查任务状态(防止并发执行)
|
||||
val currentTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
|
||||
if (currentTask == null || currentTask.status != "PENDING") {
|
||||
logger.debug("任务状态已变更,跳过执行: taskId=${taskToExecute.id}, currentStatus=${currentTask?.status}")
|
||||
return@submit
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
// 使用 start 游标分页,恢复时由 lastProcessedTradeTime 决定从何时开始拉取
|
||||
logger.info("执行回测任务: taskId=${currentTask.id}(游标分页,limit=500)")
|
||||
executionService.executeBacktest(currentTask, page = 0, size = 500)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("回测任务执行失败: taskId=${taskToExecute.id}", e)
|
||||
// 更新任务状态为 FAILED
|
||||
val failedTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
|
||||
if (failedTask != null) {
|
||||
failedTask.status = "FAILED"
|
||||
failedTask.errorMessage = e.message
|
||||
failedTask.updatedAt = System.currentTimeMillis()
|
||||
backtestTaskRepository.save(failedTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("轮询回测任务失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package com.wrbug.polymarketbot.service.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||
import com.wrbug.polymarketbot.entity.BacktestTrade
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.toJson
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 回测任务服务
|
||||
*/
|
||||
@Service
|
||||
class BacktestService(
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val backtestTradeRepository: BacktestTradeRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(BacktestService::class.java)
|
||||
|
||||
/**
|
||||
* 创建回测任务
|
||||
*/
|
||||
@Transactional
|
||||
fun createBacktestTask(request: BacktestCreateRequest): Result<BacktestTaskDto> {
|
||||
return try {
|
||||
// 1. 验证 Leader 是否存在
|
||||
val leader = leaderRepository.findById(request.leaderId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
|
||||
|
||||
// 2. 验证回测天数
|
||||
if (request.backtestDays < 1 || request.backtestDays > 15) {
|
||||
return Result.failure(IllegalArgumentException("回测天数必须在 1-15 之间"))
|
||||
}
|
||||
|
||||
// 3. 验证恢复页码(如果提供)
|
||||
if (request.pageForResume != null && request.pageForResume < 1) {
|
||||
return Result.failure(IllegalArgumentException("恢复页码必须大于 0"))
|
||||
}
|
||||
|
||||
// 4. 验证初始金额
|
||||
val initialBalance = request.initialBalance.toSafeBigDecimal()
|
||||
if (initialBalance <= BigDecimal.ZERO) {
|
||||
return Result.failure(IllegalArgumentException("初始金额必须大于 0"))
|
||||
}
|
||||
|
||||
// 4. 创建回测任务
|
||||
val task = BacktestTask(
|
||||
taskName = request.taskName.trim(),
|
||||
leaderId = request.leaderId,
|
||||
initialBalance = initialBalance,
|
||||
backtestDays = request.backtestDays,
|
||||
startTime = System.currentTimeMillis() - (request.backtestDays * 24 * 3600 * 1000),
|
||||
status = "PENDING",
|
||||
|
||||
// 跟单配置(不包含 max_position_count)
|
||||
copyMode = request.copyMode ?: "RATIO",
|
||||
copyRatio = request.copyRatio?.toSafeBigDecimal() ?: BigDecimal.ONE,
|
||||
fixedAmount = request.fixedAmount?.toSafeBigDecimal(),
|
||||
maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: "1000".toSafeBigDecimal(),
|
||||
minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: "1".toSafeBigDecimal(),
|
||||
maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: "10000".toSafeBigDecimal(),
|
||||
maxDailyOrders = request.maxDailyOrders ?: 100,
|
||||
supportSell = request.supportSell ?: true,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = if (request.keywords != null && request.keywords.isNotEmpty()) {
|
||||
request.keywords.toJson()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
)
|
||||
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
// 5. 转换为 DTO 返回
|
||||
Result.success(task.toDto(leader))
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建回测任务失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测任务列表
|
||||
*/
|
||||
fun getBacktestTaskList(request: BacktestListRequest): Result<BacktestListResponse> {
|
||||
return try {
|
||||
// 获取所有符合条件的任务
|
||||
val allTasks = when {
|
||||
request.leaderId != null && request.status != null -> {
|
||||
backtestTaskRepository.findByLeaderIdAndStatus(request.leaderId, request.status)
|
||||
}
|
||||
request.leaderId != null -> {
|
||||
backtestTaskRepository.findByLeaderId(request.leaderId)
|
||||
.filter { request.status == null || it.status == request.status }
|
||||
}
|
||||
request.status != null -> {
|
||||
backtestTaskRepository.findByStatus(request.status)
|
||||
}
|
||||
else -> {
|
||||
backtestTaskRepository.findAll()
|
||||
}
|
||||
}
|
||||
|
||||
// 排序
|
||||
val sortedTasks = when (request.sortBy) {
|
||||
"profitAmount" -> {
|
||||
if (request.sortOrder == "asc") {
|
||||
allTasks.sortedBy { it.profitAmount }
|
||||
} else {
|
||||
allTasks.sortedByDescending { it.profitAmount }
|
||||
}
|
||||
}
|
||||
"profitRate" -> {
|
||||
if (request.sortOrder == "asc") {
|
||||
allTasks.sortedBy { it.profitRate }
|
||||
} else {
|
||||
allTasks.sortedByDescending { it.profitRate }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (request.sortOrder == "asc") {
|
||||
allTasks.sortedBy { it.createdAt }
|
||||
} else {
|
||||
allTasks.sortedByDescending { it.createdAt }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
val total = sortedTasks.size
|
||||
val pagedTasks = sortedTasks
|
||||
.drop((request.page - 1) * request.size)
|
||||
.take(request.size)
|
||||
|
||||
val list = pagedTasks.map { task ->
|
||||
val leader = leaderRepository.findById(task.leaderId).orElse(null)
|
||||
task.toDto(leader)
|
||||
}
|
||||
|
||||
Result.success(
|
||||
BacktestListResponse(
|
||||
list = list,
|
||||
total = total.toLong(),
|
||||
page = request.page,
|
||||
size = request.size
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测任务列表失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测任务详情
|
||||
*/
|
||||
fun getBacktestTaskDetail(request: BacktestDetailRequest): Result<BacktestDetailResponse> {
|
||||
return try {
|
||||
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
val leader = leaderRepository.findById(task.leaderId).orElse(null)
|
||||
|
||||
val config = BacktestConfigDto(
|
||||
copyMode = task.copyMode,
|
||||
copyRatio = task.copyRatio.toPlainString(),
|
||||
fixedAmount = task.fixedAmount?.toPlainString(),
|
||||
maxOrderSize = task.maxOrderSize.toPlainString(),
|
||||
minOrderSize = task.minOrderSize.toPlainString(),
|
||||
maxDailyLoss = task.maxDailyLoss.toPlainString(),
|
||||
maxDailyOrders = task.maxDailyOrders,
|
||||
supportSell = task.supportSell,
|
||||
keywordFilterMode = task.keywordFilterMode,
|
||||
keywords = if (task.keywords != null) {
|
||||
task.keywords.fromJson<List<String>>()
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
maxPositionValue = task.maxPositionValue?.toPlainString(),
|
||||
minPrice = task.minPrice?.toPlainString(),
|
||||
maxPrice = task.maxPrice?.toPlainString()
|
||||
)
|
||||
|
||||
val statistics = BacktestStatisticsDto(
|
||||
totalTrades = task.totalTrades,
|
||||
buyTrades = task.buyTrades,
|
||||
sellTrades = task.sellTrades,
|
||||
winTrades = task.winTrades,
|
||||
lossTrades = task.lossTrades,
|
||||
winRate = task.winRate?.toPlainString() ?: "0.00",
|
||||
maxProfit = task.maxProfit?.toPlainString() ?: "0.00",
|
||||
maxLoss = task.maxLoss?.toPlainString() ?: "0.00",
|
||||
maxDrawdown = task.maxDrawdown?.toPlainString() ?: "0.00",
|
||||
avgHoldingTime = task.avgHoldingTime
|
||||
)
|
||||
|
||||
val taskDto = task.toDto(leader)
|
||||
|
||||
Result.success(
|
||||
BacktestDetailResponse(
|
||||
task = taskDto,
|
||||
config = config,
|
||||
statistics = statistics
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测任务详情失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测交易记录
|
||||
*/
|
||||
fun getBacktestTrades(request: BacktestTradeListRequest): Result<BacktestTradeListResponse> {
|
||||
return try {
|
||||
val pageRequest = PageRequest.of(
|
||||
request.page - 1,
|
||||
request.size,
|
||||
Sort.by(Sort.Order.asc("tradeTime"))
|
||||
)
|
||||
|
||||
val tradesPage = backtestTradeRepository.findByBacktestTaskId(
|
||||
request.taskId,
|
||||
pageRequest
|
||||
)
|
||||
|
||||
val list = tradesPage.content.map { trade ->
|
||||
BacktestTradeDto(
|
||||
id = trade.id!!,
|
||||
tradeTime = trade.tradeTime,
|
||||
marketId = trade.marketId,
|
||||
marketTitle = trade.marketTitle,
|
||||
side = trade.side,
|
||||
outcome = trade.outcome,
|
||||
outcomeIndex = trade.outcomeIndex,
|
||||
quantity = trade.quantity.toPlainString(),
|
||||
price = trade.price.toPlainString(),
|
||||
amount = trade.amount.toPlainString(),
|
||||
fee = trade.fee.toPlainString(),
|
||||
profitLoss = trade.profitLoss?.toPlainString(),
|
||||
balanceAfter = trade.balanceAfter.toPlainString(),
|
||||
leaderTradeId = trade.leaderTradeId
|
||||
)
|
||||
}
|
||||
|
||||
Result.success(
|
||||
BacktestTradeListResponse(
|
||||
list = list,
|
||||
total = tradesPage.totalElements,
|
||||
page = request.page,
|
||||
size = request.size
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测交易记录失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除回测任务
|
||||
*/
|
||||
@Transactional
|
||||
fun deleteBacktestTask(request: BacktestDeleteRequest): Result<Unit> {
|
||||
return try {
|
||||
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
if (task.status == "RUNNING") {
|
||||
return Result.failure(IllegalStateException("回测任务正在运行,无法删除"))
|
||||
}
|
||||
|
||||
backtestTaskRepository.deleteById(request.id)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除回测任务失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止回测任务
|
||||
*/
|
||||
@Transactional
|
||||
fun stopBacktestTask(request: BacktestStopRequest): Result<Unit> {
|
||||
return try {
|
||||
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
if (task.status != "RUNNING") {
|
||||
return Result.failure(IllegalArgumentException("回测任务未在运行中"))
|
||||
}
|
||||
|
||||
task.status = "STOPPED"
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("停止回测任务失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试回测任务
|
||||
* 从断点继续执行,保留已处理的交易记录
|
||||
*/
|
||||
@Transactional
|
||||
fun retryBacktestTask(request: BacktestRetryRequest): Result<Unit> {
|
||||
return try {
|
||||
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
if (task.status == "RUNNING") {
|
||||
return Result.failure(IllegalArgumentException("回测任务正在运行中,无需重试"))
|
||||
}
|
||||
|
||||
// 重置任务状态为 PENDING,进度保持不变
|
||||
task.status = "PENDING"
|
||||
task.errorMessage = null
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
|
||||
// 不清理已处理的交易记录,保留恢复点
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("重试回测任务失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前配置重新测试:基于已完成的回测任务创建一份相同配置的新任务(名称可修改)
|
||||
*/
|
||||
@Transactional
|
||||
fun rerunBacktestTask(request: BacktestRerunRequest): Result<BacktestTaskDto> {
|
||||
return try {
|
||||
val source = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
if (source.status != "COMPLETED") {
|
||||
return Result.failure(IllegalStateException("仅支持对已完成的回测任务重新测试"))
|
||||
}
|
||||
|
||||
val newTaskName = request.taskName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: "${source.taskName} (副本)"
|
||||
|
||||
val newTask = BacktestTask(
|
||||
taskName = newTaskName,
|
||||
leaderId = source.leaderId,
|
||||
initialBalance = source.initialBalance,
|
||||
backtestDays = source.backtestDays,
|
||||
startTime = source.startTime,
|
||||
status = "PENDING",
|
||||
copyMode = source.copyMode,
|
||||
copyRatio = source.copyRatio,
|
||||
fixedAmount = source.fixedAmount,
|
||||
maxOrderSize = source.maxOrderSize,
|
||||
minOrderSize = source.minOrderSize,
|
||||
maxDailyLoss = source.maxDailyLoss,
|
||||
maxDailyOrders = source.maxDailyOrders,
|
||||
supportSell = source.supportSell,
|
||||
keywordFilterMode = source.keywordFilterMode,
|
||||
keywords = source.keywords,
|
||||
maxPositionValue = source.maxPositionValue,
|
||||
minPrice = source.minPrice,
|
||||
maxPrice = source.maxPrice
|
||||
)
|
||||
|
||||
backtestTaskRepository.save(newTask)
|
||||
val leader = leaderRepository.findById(newTask.leaderId).orElse(null)
|
||||
Result.success(newTask.toDto(leader))
|
||||
} catch (e: Exception) {
|
||||
logger.error("按配置重新测试失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展函数:BacktestTask 转 DTO
|
||||
*/
|
||||
private fun BacktestTask.toDto(leader: Leader?): BacktestTaskDto {
|
||||
return BacktestTaskDto(
|
||||
id = this.id!!,
|
||||
taskName = this.taskName,
|
||||
leaderId = this.leaderId,
|
||||
leaderName = leader?.leaderName,
|
||||
leaderAddress = leader?.leaderAddress,
|
||||
initialBalance = this.initialBalance.toPlainString(),
|
||||
finalBalance = this.finalBalance?.toPlainString(),
|
||||
profitAmount = this.profitAmount?.toPlainString(),
|
||||
profitRate = this.profitRate?.toPlainString(),
|
||||
backtestDays = this.backtestDays,
|
||||
startTime = this.startTime,
|
||||
endTime = this.endTime,
|
||||
status = this.status,
|
||||
progress = this.progress,
|
||||
totalTrades = this.totalTrades,
|
||||
createdAt = this.createdAt,
|
||||
executionStartedAt = this.executionStartedAt,
|
||||
executionFinishedAt = this.executionFinishedAt
|
||||
)
|
||||
}
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package com.wrbug.polymarketbot.service.binance
|
||||
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 自动最小价差:按周期计算。每个周期首次需要时,拉取该周期前的 20 根已收盘 K 线,按方向筛选、IQR 剔除后求平均,缓存 100% 基准值 (marketSlugPrefix, interval, period)。
|
||||
* 触发时由调用方按窗口进度计算动态系数(100%→50%)后得到有效最小价差。不在保存策略时计算。
|
||||
*/
|
||||
@Service
|
||||
class BinanceKlineAutoSpreadService(
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java)
|
||||
|
||||
/** 市场 slug 前缀 -> Binance 交易对映射 */
|
||||
private val marketToSymbol = mapOf(
|
||||
"btc-updown" to "BTCUSDC",
|
||||
"eth-updown" to "ETHUSDC",
|
||||
"sol-updown" to "SOLUSDC",
|
||||
"xrp-updown" to "XRPUSDC"
|
||||
)
|
||||
|
||||
private val historyLimit = 20
|
||||
private val minSamplesAfterIqr = 3
|
||||
|
||||
/** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown),100% 基准价差 */
|
||||
private val cache = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||
|
||||
/** 缓存保留时间(秒),超过则清理,防止无界增长 */
|
||||
private val cacheExpireSeconds = 3600L
|
||||
|
||||
/** 从市场 slug 前缀获取 Binance 交易对;支持完整 slug(如 eth-updown-5m)或前缀(如 eth-updown) */
|
||||
private fun getSymbol(marketSlugPrefix: String): String? {
|
||||
val base = marketSlugPrefix.lowercase().removeSuffix("-15m").removeSuffix("-5m")
|
||||
return marketToSymbol[base]
|
||||
}
|
||||
|
||||
private fun cacheKey(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): String {
|
||||
return "$marketSlugPrefix-$intervalSeconds-$periodStartUnix"
|
||||
}
|
||||
|
||||
/** 清理已过期的价差缓存,避免内存泄漏 */
|
||||
private fun cleanExpiredCache() {
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val expireThreshold = nowSeconds - cacheExpireSeconds
|
||||
val keysToRemove = cache.keys.filter { key ->
|
||||
// key 格式: marketSlugPrefix-intervalSeconds-periodStartUnix
|
||||
val parts = key.split('-')
|
||||
if (parts.size >= 3) {
|
||||
parts.last().toLongOrNull()?.let { it < expireThreshold } ?: false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach { cache.remove(it) }
|
||||
}
|
||||
|
||||
/** 返回该周期、该方向的 100% 基准价差,供调用方按窗口进度应用动态系数。 */
|
||||
fun getAutoMinSpreadBase(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
|
||||
val key = cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)
|
||||
val (up, down) = cache[key] ?: run {
|
||||
computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix) ?: return null
|
||||
}
|
||||
return if (outcomeIndex == 0) up else down
|
||||
}
|
||||
|
||||
/** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */
|
||||
fun computeAndCache(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
||||
cleanExpiredCache()
|
||||
val symbol = getSymbol(marketSlugPrefix) ?: run {
|
||||
logger.warn("不支持的市场 slug 前缀: $marketSlugPrefix")
|
||||
return null
|
||||
}
|
||||
val intervalStr = if (intervalSeconds == 300) "5m" else "15m"
|
||||
val endTimeMs = periodStartUnix * 1000L
|
||||
val klines = fetchKlines(symbol, intervalStr, historyLimit, endTime = endTimeMs) ?: return null
|
||||
val spreadsUp = mutableListOf<BigDecimal>()
|
||||
val spreadsDown = mutableListOf<BigDecimal>()
|
||||
for (k in klines) {
|
||||
if (k.size < 5) continue
|
||||
val openP = k.getOrNull(1)?.toString()?.toSafeBigDecimal() ?: continue
|
||||
val closeP = k.getOrNull(4)?.toString()?.toSafeBigDecimal() ?: continue
|
||||
if (closeP > openP) spreadsUp.add(closeP.subtract(openP))
|
||||
if (closeP < openP) spreadsDown.add(openP.subtract(closeP))
|
||||
}
|
||||
val baseUp = averageAfterIqr(spreadsUp).setScale(8, RoundingMode.HALF_UP)
|
||||
val baseDown = averageAfterIqr(spreadsDown).setScale(8, RoundingMode.HALF_UP)
|
||||
cache[cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)] = baseUp to baseDown
|
||||
logger.info(
|
||||
"加密价差策略自动价差已计算并缓存(100%基准): market=$marketSlugPrefix symbol=$symbol interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
|
||||
"Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " +
|
||||
"Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}"
|
||||
)
|
||||
return baseUp to baseDown
|
||||
}
|
||||
|
||||
private fun fetchKlines(symbol: String, interval: String, limit: Int, endTime: Long? = null): List<List<Any>>? {
|
||||
return try {
|
||||
val api = retrofitFactory.createBinanceApi()
|
||||
val call = api.getKlines(symbol = symbol, interval = interval, limit = limit, endTime = endTime)
|
||||
val response = call.execute()
|
||||
if (response.isSuccessful && response.body() != null) response.body() else null
|
||||
} catch (e: Exception) {
|
||||
logger.warn("拉取币安 K 线失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IQR 剔除异常值后求平均;若剔除后样本数 < minSamplesAfterIqr 则不剔除,用全量求平均。
|
||||
*/
|
||||
private fun averageAfterIqr(list: List<BigDecimal>): BigDecimal {
|
||||
if (list.isEmpty()) return BigDecimal.ZERO
|
||||
val sorted = list.sorted()
|
||||
val n = sorted.size
|
||||
val q1Idx = (n * 0.25).toInt().coerceIn(0, n - 1)
|
||||
val q3Idx = (n * 0.75).toInt().coerceIn(0, n - 1)
|
||||
val q1 = sorted[q1Idx]
|
||||
val q3 = sorted[q3Idx]
|
||||
val iqr = q3.subtract(q1)
|
||||
val lower = q1.subtract(iqr.multiply(BigDecimal("1.5")))
|
||||
val upper = q3.add(iqr.multiply(BigDecimal("1.5")))
|
||||
val filtered = sorted.filter { it >= lower && it <= upper }
|
||||
val use = if (filtered.size < minSamplesAfterIqr) sorted else filtered
|
||||
return use.fold(BigDecimal.ZERO) { a, b -> a.add(b) }.divide(BigDecimal(use.size), 18, RoundingMode.HALF_UP)
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package com.wrbug.polymarketbot.service.binance
|
||||
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 币安 K 线 WebSocket:按需订阅加密价差策略使用的币种 5m/15m,维护当前周期 (open, close),供价差校验使用。
|
||||
* 仅当存在启用策略且策略使用到某市场时才订阅对应币种,无策略时不建立连接。
|
||||
*/
|
||||
@Service
|
||||
class BinanceKlineService {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BinanceKlineService::class.java)
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
private val wsBase = "wss://stream.binance.com:9443"
|
||||
private val client by lazy {
|
||||
createClient().build()
|
||||
}
|
||||
|
||||
/** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (open, close) */
|
||||
private val openCloseByPeriod = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||
|
||||
/** 市场 slug 前缀(如 btc-updown)-> Binance 交易对映射 */
|
||||
private val marketToSymbol = mapOf(
|
||||
"btc-updown" to "BTCUSDC",
|
||||
"eth-updown" to "ETHUSDC",
|
||||
"sol-updown" to "SOLUSDC",
|
||||
"xrp-updown" to "XRPUSDC"
|
||||
)
|
||||
|
||||
/** 已连接的 WebSocket: wsKey (symbol-interval) -> WebSocket */
|
||||
private val connectedWebSockets = ConcurrentHashMap<String, WebSocket>()
|
||||
/** 当前需要订阅的完整市场集合(如 btc-updown-5m、btc-updown-15m),由加密价差策略刷新时更新 */
|
||||
private val requiredMarketPrefixes = AtomicReference<Set<String>>(emptySet())
|
||||
private val subscriptionLock = Any()
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
/** 解析完整市场 slug(如 btc-updown-5m)为 (basePrefix, interval),不支持则返回 null */
|
||||
private fun parseMarketSlug(full: String): Pair<String, String>? {
|
||||
val lower = full.lowercase()
|
||||
return when {
|
||||
lower.endsWith("-5m") -> Pair(lower.removeSuffix("-5m"), "5m")
|
||||
lower.endsWith("-15m") -> Pair(lower.removeSuffix("-15m"), "15m")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** 从市场 base 前缀(如 btc-updown)获取 Binance 交易对 */
|
||||
private fun getSymbol(basePrefix: String): String? = marketToSymbol[basePrefix]
|
||||
|
||||
private fun key(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): String {
|
||||
return "$marketSlugPrefix-$intervalSeconds-$periodStartUnix"
|
||||
}
|
||||
|
||||
fun getCurrentOpenClose(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
||||
return openCloseByPeriod[key(marketSlugPrefix, intervalSeconds, periodStartUnix)]
|
||||
}
|
||||
|
||||
/** 供 API 健康检查使用:各币种各周期的连接状态 */
|
||||
fun getConnectionStatuses(): Map<String, Boolean> {
|
||||
return connectedWebSockets.keys.associateWith { connectedWebSockets[it] != null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 按需更新订阅:仅订阅策略用到的 (币种, 周期),例如只开 btc 5min 则只建 btc 5min K 线连接。
|
||||
* 由 CryptoTailOrderbookWsService 在刷新订阅时根据启用策略的 marketSlugPrefix 调用。
|
||||
* @param marketPrefixes 当前启用策略用到的完整市场集合,如 ["btc-updown-5m"] 或 ["btc-updown-5m", "eth-updown-15m"];空集合时关闭所有连接
|
||||
*/
|
||||
fun updateSubscriptions(marketPrefixes: Set<String>) {
|
||||
val normalized = marketPrefixes.map { it.lowercase() }.toSet()
|
||||
|
||||
val parsed = normalized.mapNotNull { full ->
|
||||
parseMarketSlug(full)?.let { (base, interval) ->
|
||||
getSymbol(base)?.let { symbol -> Triple(full, symbol, interval) }
|
||||
}
|
||||
}.toSet()
|
||||
val wsKeysNeeded = parsed.map { (_, symbol, interval) -> "$symbol-$interval" }.toSet()
|
||||
|
||||
// 检查是否有需要的 WebSocket 连接缺失(可能因网络问题断开)
|
||||
val hasMissingConnection = wsKeysNeeded.any { it !in connectedWebSockets.keys }
|
||||
|
||||
// 只有当集合相同且所有需要的连接都存在时才跳过
|
||||
if (normalized == requiredMarketPrefixes.get() && !hasMissingConnection) return
|
||||
requiredMarketPrefixes.set(normalized)
|
||||
synchronized(subscriptionLock) {
|
||||
connectedWebSockets.keys.toList().forEach { wsKey ->
|
||||
if (wsKey !in wsKeysNeeded) {
|
||||
connectedWebSockets.remove(wsKey)?.close(1000, "subscription_update")
|
||||
logger.info("币安 K 线 WS 已关闭(无策略使用): $wsKey")
|
||||
}
|
||||
}
|
||||
parsed.forEach { (fullPrefix, symbol, interval) ->
|
||||
connectStream(symbol, interval, fullPrefix) { marketPrefixParam, intervalSec, tMs, openP, closeP ->
|
||||
val periodSec = tMs / 1000
|
||||
openCloseByPeriod[key(marketPrefixParam, intervalSec, periodSec)] = openP to closeP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectStream(
|
||||
symbol: String,
|
||||
interval: String,
|
||||
marketPrefix: String,
|
||||
onKline: (marketPrefix: String, intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
|
||||
) {
|
||||
val streamName = "${symbol.lowercase()}@kline_$interval"
|
||||
val wsKey = "$symbol-$interval"
|
||||
if (connectedWebSockets[wsKey] != null) return
|
||||
|
||||
val url = "$wsBase/ws/$streamName"
|
||||
val intervalSeconds = when (interval) {
|
||||
"5m" -> 300
|
||||
"15m" -> 900
|
||||
else -> 300
|
||||
}
|
||||
val request = Request.Builder().url(url).build()
|
||||
client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
connectedWebSockets[wsKey] = webSocket
|
||||
logger.info("币安 K 线 WS 已连接: $streamName")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
parseKlineMessage(text)?.let { (tMs, o, c) ->
|
||||
onKline(marketPrefix, intervalSeconds, tMs, o, c)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
connectedWebSockets.remove(wsKey)
|
||||
logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}")
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
connectedWebSockets.remove(wsKey)
|
||||
if (code != 1000) scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
connectedWebSockets.remove(wsKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun parseKlineMessage(text: String): Triple<Long, BigDecimal, BigDecimal>? {
|
||||
return try {
|
||||
val json = com.google.gson.JsonParser.parseString(text).asJsonObject
|
||||
if (json.get("e")?.asString != "kline") return null
|
||||
val k = json.getAsJsonObject("k") ?: return null
|
||||
val tMs = k.get("t")?.asLong ?: return null
|
||||
val o = k.get("o")?.asString?.toSafeBigDecimal() ?: return null
|
||||
val c = k.get("c")?.asString?.toSafeBigDecimal() ?: return null
|
||||
Triple(tMs, o, c)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("解析币安 K 线消息失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectJob?.isActive == true) return
|
||||
reconnectJob = scope.launch {
|
||||
delay(3_000)
|
||||
reconnectJob = null
|
||||
val current = requiredMarketPrefixes.get()
|
||||
connectedWebSockets.values.forEach { it.close(1000, "reconnect") }
|
||||
connectedWebSockets.clear()
|
||||
logger.info("币安 K 线 WS 尝试重连")
|
||||
// 清空 requiredMarketPrefixes,否则 updateSubscriptions(current) 内会因 normalized == requiredMarketPrefixes.get() 直接 return,不会重新 connectStream
|
||||
requiredMarketPrefixes.set(emptySet())
|
||||
updateSubscriptions(current)
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
reconnectJob?.cancel()
|
||||
connectedWebSockets.values.forEach { it.close(1000, "shutdown") }
|
||||
connectedWebSockets.clear()
|
||||
}
|
||||
}
|
||||
+338
-26
@@ -7,13 +7,18 @@ import com.wrbug.polymarketbot.api.JsonRpcResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PositionResponse
|
||||
import com.wrbug.polymarketbot.api.ValueResponse
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.dto.PositionDto
|
||||
import com.wrbug.polymarketbot.dto.WalletBalanceResponse
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import kotlinx.coroutines.delay
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
@@ -26,8 +31,6 @@ import java.math.BigInteger
|
||||
*/
|
||||
@Service
|
||||
class BlockchainService(
|
||||
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
|
||||
private val dataApiBaseUrl: String,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val relayClientService: RelayClientService,
|
||||
private val rpcNodeService: RpcNodeService,
|
||||
@@ -52,6 +55,9 @@ class BlockchainService(
|
||||
|
||||
// ConditionalTokens 合约地址(Polygon 主网)
|
||||
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||
|
||||
// Neg Risk WrappedCollateral 合约地址(Polygon,解包后得 USDC.e)
|
||||
private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
|
||||
|
||||
// 空集合ID(用于计算collectionId)
|
||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
@@ -61,10 +67,10 @@ class BlockchainService(
|
||||
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
|
||||
|
||||
private val dataApi: PolymarketDataApi by lazy {
|
||||
val baseUrl = if (dataApiBaseUrl.endsWith("/")) {
|
||||
dataApiBaseUrl.dropLast(1)
|
||||
val baseUrl = if (PolymarketConstants.DATA_API_BASE_URL.endsWith("/")) {
|
||||
PolymarketConstants.DATA_API_BASE_URL.dropLast(1)
|
||||
} else {
|
||||
dataApiBaseUrl
|
||||
PolymarketConstants.DATA_API_BASE_URL
|
||||
}
|
||||
val okHttpClient = createClient()
|
||||
.followRedirects(true)
|
||||
@@ -92,13 +98,13 @@ class BlockchainService(
|
||||
* 2. Safe Proxy(MetaMask 钱包用户)- 通过合约调用获取地址
|
||||
*
|
||||
* @param walletAddress 用户的钱包地址(EOA)
|
||||
* @param walletType 钱包类型:"magic"(默认)或 "safe"
|
||||
* @param walletType 钱包类型:MAGIC(默认)或 SAFE
|
||||
* @return 代理钱包地址
|
||||
*/
|
||||
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
|
||||
suspend fun getProxyAddress(walletAddress: String, walletType: WalletType = WalletType.MAGIC): Result<String> {
|
||||
return try {
|
||||
when (walletType.lowercase()) {
|
||||
"safe" -> {
|
||||
when (walletType) {
|
||||
WalletType.SAFE -> {
|
||||
// Safe Proxy(MetaMask 用户)
|
||||
val safeProxyResult = getSafeProxyAddress(walletAddress)
|
||||
if (safeProxyResult.isSuccess) {
|
||||
@@ -109,7 +115,7 @@ class BlockchainService(
|
||||
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
WalletType.MAGIC -> {
|
||||
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
|
||||
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
|
||||
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
|
||||
@@ -241,6 +247,62 @@ class BlockchainService(
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理钱包是否已部署(链上有合约代码)
|
||||
* @param proxyAddress 代理钱包地址
|
||||
* @return 已部署返回 true
|
||||
*/
|
||||
suspend fun isProxyDeployed(proxyAddress: String): Boolean {
|
||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||
return false
|
||||
}
|
||||
return isContract(proxyAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 ERC20 USDC 授权额度 allowance(owner, spender)
|
||||
* @param owner 代币持有者地址(代理钱包地址)
|
||||
* @param spender 被授权方地址(如 CTF Exchange)
|
||||
* @return 授权额度(原始值,USDC 为 6 位小数,需除以 1e6 为显示值)
|
||||
*/
|
||||
suspend fun getUsdcAllowance(owner: String, spender: String): Result<BigInteger> {
|
||||
return try {
|
||||
if (owner.isBlank() || spender.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("owner 或 spender 不能为空"))
|
||||
}
|
||||
val rpcApi = polygonRpcApi
|
||||
// ERC20 allowance(address owner, address spender) 选择器
|
||||
val functionSelector = "0xdd62ed3e"
|
||||
val ownerEncoded = EthereumUtils.encodeAddress(owner)
|
||||
val spenderEncoded = EthereumUtils.encodeAddress(spender)
|
||||
val data = functionSelector + ownerEncoded + spenderEncoded
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to usdcContractAddress,
|
||||
"data" to data
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
val rpcResponse = response.body()!!
|
||||
if (rpcResponse.error != null) {
|
||||
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
|
||||
}
|
||||
val hexResult = rpcResponse.result?.asString ?: return Result.failure(Exception("RPC 响应 result 为空"))
|
||||
val allowance = EthereumUtils.decodeUint256(hexResult)
|
||||
Result.success(allowance)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询 USDC 授权额度失败: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户 USDC 余额
|
||||
@@ -257,7 +319,6 @@ class BlockchainService(
|
||||
return Result.failure(IllegalArgumentException("代理地址不能为空"))
|
||||
}
|
||||
|
||||
|
||||
// 使用 RPC 查询 USDC 余额(使用代理地址)
|
||||
val balance = queryUsdcBalanceViaRpc(proxyAddress)
|
||||
Result.success(balance)
|
||||
@@ -266,6 +327,82 @@ class BlockchainService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询钱包余额(通用方法)
|
||||
* 用于 Account 和 Leader 的余额查询
|
||||
* @param walletAddress 钱包地址(代理地址,Polymarket 使用代理地址存储资产)
|
||||
* @return WalletBalanceResponse 包含可用余额、仓位余额、总余额和持仓列表
|
||||
*/
|
||||
suspend fun getWalletBalance(walletAddress: String): Result<WalletBalanceResponse> {
|
||||
return try {
|
||||
if (walletAddress.isBlank()) {
|
||||
logger.error("钱包地址为空,无法查询余额")
|
||||
return Result.failure(IllegalArgumentException("钱包地址不能为空"))
|
||||
}
|
||||
|
||||
// 1. 查询持仓信息(用于返回持仓列表)
|
||||
val positionsResult = getPositions(walletAddress)
|
||||
val positions = if (positionsResult.isSuccess) {
|
||||
// 过滤掉价值为0的仓位
|
||||
positionsResult.getOrNull()?.filter { pos ->
|
||||
val currentValue = pos.currentValue ?: 0.0
|
||||
currentValue > 0
|
||||
}?.map { pos ->
|
||||
PositionDto(
|
||||
marketId = pos.conditionId ?: "",
|
||||
title = pos.title,
|
||||
side = pos.outcome ?: "",
|
||||
quantity = pos.size?.toString() ?: "0",
|
||||
avgPrice = pos.avgPrice?.toString() ?: "0",
|
||||
currentValue = pos.currentValue?.toString() ?: "0",
|
||||
pnl = pos.cashPnl?.toString()
|
||||
)
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// 2. 使用 /value 接口获取仓位总价值
|
||||
val positionBalanceResult = getTotalValue(walletAddress)
|
||||
val positionBalance = if (positionBalanceResult.isSuccess) {
|
||||
positionBalanceResult.getOrNull() ?: "0"
|
||||
} else {
|
||||
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
// 3. 查询可用余额(通过 RPC 查询 USDC 余额)
|
||||
val availableBalanceResult = getUsdcBalance(
|
||||
walletAddress = walletAddress,
|
||||
proxyAddress = walletAddress
|
||||
)
|
||||
val availableBalance = if (availableBalanceResult.isSuccess) {
|
||||
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
|
||||
} else {
|
||||
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
|
||||
val error = availableBalanceResult.exceptionOrNull()
|
||||
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
|
||||
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
|
||||
}
|
||||
|
||||
// 4. 计算总余额 = 可用余额 + 仓位余额
|
||||
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
|
||||
|
||||
Result.success(
|
||||
WalletBalanceResponse(
|
||||
availableBalance = availableBalance,
|
||||
positionBalance = positionBalance,
|
||||
totalBalance = totalBalance.toPlainString(),
|
||||
positions = positions
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询钱包余额失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 RPC 查询 USDC 余额
|
||||
@@ -504,45 +641,220 @@ class BlockchainService(
|
||||
|
||||
/**
|
||||
* 赎回仓位
|
||||
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
|
||||
*
|
||||
* 使用 RelayClientService 实现,完全参考 TypeScript 项目的实现方式
|
||||
*
|
||||
* Safe 账户通过代理 execTransaction 调用,Magic 账户通过 Builder Relayer PROXY(Gasless)执行
|
||||
*
|
||||
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
|
||||
* @param proxyAddress 代理地址(Gnosis Safe 代理钱包地址)
|
||||
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
|
||||
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
||||
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex,例如 [1] 表示 outcome 0,[2] 表示 outcome 1)
|
||||
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex)
|
||||
* @param isNegRisk 是否为 Neg Risk 市场(true 时使用 WrappedCollateral 作为抵押品)
|
||||
* @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun redeemPositions(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
conditionId: String,
|
||||
indexSets: List<BigInteger>
|
||||
indexSets: List<BigInteger>,
|
||||
isNegRisk: Boolean = false,
|
||||
walletType: WalletType = WalletType.SAFE
|
||||
): Result<String> {
|
||||
return try {
|
||||
// 验证参数
|
||||
if (indexSets.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
|
||||
}
|
||||
|
||||
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
||||
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
|
||||
}
|
||||
|
||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||
}
|
||||
|
||||
// 使用 RelayClientService 创建赎回交易并执行
|
||||
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
|
||||
relayClientService.execute(privateKey, proxyAddress, redeemTx)
|
||||
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
|
||||
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
|
||||
} catch (e: Exception) {
|
||||
logger.error("赎回仓位失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 批量赎回多个市场的仓位(使用 MultiSend 合并为一笔交易)
|
||||
* 仅支持 Safe 钱包类型,Magic 钱包不支持 MultiSend
|
||||
*
|
||||
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
|
||||
* @param proxyAddress 代理地址(Safe 代理钱包地址)
|
||||
* @param redeemRequests 赎回请求列表,每个元素是 (conditionId, indexSets, isNegRisk)
|
||||
* @param walletType 钱包类型:仅支持 SAFE
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun redeemPositionsBatch(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
redeemRequests: List<Triple<String, List<BigInteger>, Boolean>>,
|
||||
walletType: WalletType = WalletType.SAFE
|
||||
): Result<String> {
|
||||
return try {
|
||||
if (redeemRequests.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("redeemRequests 不能为空"))
|
||||
}
|
||||
|
||||
// Magic 钱包不支持 MultiSend
|
||||
if (walletType == WalletType.MAGIC) {
|
||||
return Result.failure(IllegalArgumentException("Magic 钱包不支持 MultiSend 批量赎回,请使用逐笔赎回"))
|
||||
}
|
||||
|
||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||
}
|
||||
|
||||
// 验证所有 conditionId 格式
|
||||
for ((conditionId, _, _) in redeemRequests) {
|
||||
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
||||
return Result.failure(IllegalArgumentException("conditionId 格式错误: $conditionId"))
|
||||
}
|
||||
}
|
||||
|
||||
// 创建每个市场的赎回交易(Neg Risk 市场使用 WrappedCollateral)
|
||||
val redeemTxs = redeemRequests.map { (conditionId, indexSets, isNegRisk) ->
|
||||
if (indexSets.isEmpty()) {
|
||||
throw IllegalArgumentException("indexSets 不能为空: $conditionId")
|
||||
}
|
||||
relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
|
||||
}
|
||||
|
||||
// 使用 MultiSend 合并所有交易
|
||||
val multiSendTx = relayClientService.createMultiSendTx(redeemTxs)
|
||||
|
||||
logger.info("批量赎回: 合并 ${redeemRequests.size} 个市场为一笔交易")
|
||||
|
||||
relayClientService.execute(privateKey, proxyAddress, multiSendTx, walletType)
|
||||
} catch (e: Exception) {
|
||||
logger.error("批量赎回仓位失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询等待交易上链并确认成功
|
||||
* @param txHash 交易 hash(0x 开头)
|
||||
* @param maxWaitMs 最大等待毫秒数
|
||||
* @param pollIntervalMs 轮询间隔毫秒数
|
||||
* @return 成功返回 Unit,超时或 revert 返回 Result.failure
|
||||
*/
|
||||
suspend fun waitForTransactionConfirmed(
|
||||
txHash: String,
|
||||
maxWaitMs: Long = 120_000,
|
||||
pollIntervalMs: Long = 3_000
|
||||
): Result<Unit> {
|
||||
val rpcApi = polygonRpcApi
|
||||
val start = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - start < maxWaitMs) {
|
||||
val req = JsonRpcRequest(method = "eth_getTransactionReceipt", params = listOf(txHash))
|
||||
val response = rpcApi.call(req)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
delay(pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
val body = response.body()!!
|
||||
if (body.error != null) {
|
||||
delay(pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
val result = body.result
|
||||
if (result == null || result.isJsonNull) {
|
||||
delay(pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
val status = result.asJsonObject?.get("status")?.asString
|
||||
if (status == null) {
|
||||
delay(pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
return when (status) {
|
||||
"0x1" -> Result.success(Unit)
|
||||
"0x0" -> Result.failure(Exception("交易已上链但执行失败 (revert)"))
|
||||
else -> Result.failure(Exception("交易状态异常: $status"))
|
||||
}
|
||||
}
|
||||
return Result.failure(Exception("等待交易确认超时 (${maxWaitMs}ms)"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询代理地址的 WCOL(Wrapped Collateral)余额(raw,6 位小数)
|
||||
*/
|
||||
suspend fun getWcolBalance(proxyAddress: String): Result<BigInteger> {
|
||||
val rpcApi = polygonRpcApi
|
||||
val functionSelector = "0x70a08231" // balanceOf(address)
|
||||
val paddedAddress = proxyAddress.removePrefix("0x").lowercase().padStart(64, '0')
|
||||
val data = functionSelector + paddedAddress
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to wcolContractAddress,
|
||||
"data" to data
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
return Result.failure(Exception("查询 WCOL 余额失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
val rpcResponse = response.body()!!
|
||||
if (rpcResponse.error != null) {
|
||||
return Result.failure(Exception("查询 WCOL 余额失败: ${rpcResponse.error.message}"))
|
||||
}
|
||||
val hexBalance = rpcResponse.result?.asString ?: return Result.failure(Exception("WCOL 余额结果为空"))
|
||||
val balance = EthereumUtils.decodeUint256(hexBalance)
|
||||
return Result.success(balance)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将代理钱包内的 WCOL 解包为 USDC.e(解包后转入代理地址)
|
||||
* 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可转为 USDC.e 以便显示/使用。
|
||||
*
|
||||
* Safe 与 Magic 使用同一套逻辑:同一 [createUnwrapWcolTx] + [RelayClientService.execute];
|
||||
* Safe 走 execTransaction,Magic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount),USDC.e 转入 proxyAddress。
|
||||
*
|
||||
* @param privateKey 主钱包私钥
|
||||
* @param proxyAddress 代理地址(Safe 或 Magic 代理)
|
||||
* @param walletType 钱包类型(SAFE / MAGIC),用于选择 Relayer 执行路径
|
||||
* @return 成功返回交易 hash,余额为 0 返回 null,失败返回 Result.failure
|
||||
*/
|
||||
suspend fun unwrapWcolForProxy(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
walletType: WalletType
|
||||
): Result<String?> {
|
||||
return try {
|
||||
val balanceResult = getWcolBalance(proxyAddress)
|
||||
val balance = balanceResult.getOrElse {
|
||||
logger.warn("查询 WCOL 余额失败,跳过解包: ${it.message}")
|
||||
return Result.success(null)
|
||||
}
|
||||
if (balance == BigInteger.ZERO) {
|
||||
return Result.success(null)
|
||||
}
|
||||
val unwrapTx = relayClientService.createUnwrapWcolTx(proxyAddress, balance)
|
||||
val executeResult = relayClientService.execute(privateKey, proxyAddress, unwrapTx, walletType)
|
||||
executeResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
logger.info("WCOL 解包成功: proxy=${proxyAddress.take(10)}..., txHash=$txHash")
|
||||
Result.success(txHash)
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("WCOL 解包失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("WCOL 解包异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理钱包的 nonce(用于构建 Safe 交易)
|
||||
*/
|
||||
|
||||
@@ -89,8 +89,6 @@ class MarketPollingService(
|
||||
*/
|
||||
private suspend fun checkAndUpdateMissingMarkets() {
|
||||
try {
|
||||
logger.debug("开始检查缺失的市场信息...")
|
||||
|
||||
// 1. 获取所有买入订单的市场ID(去重)
|
||||
val allOrders = copyOrderTrackingRepository.findAll()
|
||||
val marketIds = allOrders.map { it.marketId }.distinct()
|
||||
@@ -99,9 +97,6 @@ class MarketPollingService(
|
||||
logger.debug("没有找到任何订单,跳过市场信息检查")
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
|
||||
|
||||
// 2. 检查哪些市场信息在数据库中缺失
|
||||
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
||||
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
||||
@@ -113,7 +108,6 @@ class MarketPollingService(
|
||||
}
|
||||
|
||||
if (validMissingMarketIds.isEmpty()) {
|
||||
logger.debug("所有市场信息都已存在,无需更新")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+61
-3
@@ -9,6 +9,8 @@ import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import com.github.benmanes.caffeine.cache.Cache
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
|
||||
/**
|
||||
* 市场价格服务
|
||||
@@ -27,6 +29,21 @@ class MarketPriceService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
|
||||
|
||||
/**
|
||||
* 已结算市场的价格缓存
|
||||
* Key: "marketId:outcomeIndex"
|
||||
* Value: BigDecimal (1.0 或 0.0)
|
||||
*
|
||||
* 缓存策略:
|
||||
* - 最大缓存 10,000 个已结算市场
|
||||
* - 永不过期(已结算的市场状态永不改变)
|
||||
* - 内存占用约: 10,000 * ~100 bytes = ~1MB
|
||||
*/
|
||||
private val settledMarketCache: Cache<String, BigDecimal> = Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.recordStats() // 启用统计信息
|
||||
.build()
|
||||
|
||||
/**
|
||||
* 获取当前市场最新价
|
||||
* 优先级:
|
||||
@@ -83,9 +100,20 @@ class MarketPriceService(
|
||||
* - payout == 0(输了)→ 返回 0.0
|
||||
* 如果市场未结算或查询失败,返回 null
|
||||
*
|
||||
* 使用缓存优化:已结算的市场结果会被缓存,避免重复 RPC 调用
|
||||
*
|
||||
* @return Pair<BigDecimal?, Boolean> 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted)
|
||||
*/
|
||||
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
|
||||
// 1. 先检查缓存
|
||||
val cacheKey = "$marketId:$outcomeIndex"
|
||||
val cachedPrice = settledMarketCache.getIfPresent(cacheKey)
|
||||
if (cachedPrice != null) {
|
||||
logger.debug("从缓存获取已结算市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$cachedPrice")
|
||||
return Pair(cachedPrice, false)
|
||||
}
|
||||
|
||||
// 2. 缓存未命中,发起 RPC 查询
|
||||
return try {
|
||||
val chainResult = blockchainService.getCondition(marketId)
|
||||
chainResult.fold(
|
||||
@@ -96,11 +124,17 @@ class MarketPriceService(
|
||||
when {
|
||||
payout > BigInteger.ZERO -> {
|
||||
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return Pair(BigDecimal.ONE, false)
|
||||
val price = BigDecimal.ONE
|
||||
// 缓存已结算的结果
|
||||
settledMarketCache.put(cacheKey, price)
|
||||
return Pair(price, false)
|
||||
}
|
||||
payout == BigInteger.ZERO -> {
|
||||
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return Pair(BigDecimal.ZERO, false)
|
||||
val price = BigDecimal.ZERO
|
||||
// 缓存已结算的结果
|
||||
settledMarketCache.put(cacheKey, price)
|
||||
return Pair(price, false)
|
||||
}
|
||||
else -> {
|
||||
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
@@ -109,7 +143,7 @@ class MarketPriceService(
|
||||
}
|
||||
} else {
|
||||
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
|
||||
Pair(null, false)
|
||||
Pair(null, false) // 未结算的市场不缓存
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -290,5 +324,29 @@ class MarketPriceService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
* 用于监控缓存命中率和性能
|
||||
*/
|
||||
fun getCacheStats(): String {
|
||||
val stats = settledMarketCache.stats()
|
||||
return """
|
||||
已结算市场缓存统计:
|
||||
- 缓存条目数: ${settledMarketCache.estimatedSize()}
|
||||
- 命中次数: ${stats.hitCount()}
|
||||
- 未命中次数: ${stats.missCount()}
|
||||
- 命中率: ${"%.2f".format(stats.hitRate() * 100)}%
|
||||
- 总请求次数: ${stats.requestCount()}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空缓存(测试或管理用)
|
||||
*/
|
||||
fun clearSettledMarketCache() {
|
||||
settledMarketCache.invalidateAll()
|
||||
logger.info("已清空已结算市场缓存")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.entity.Market
|
||||
import com.wrbug.polymarketbot.repository.MarketRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.getEventSlug
|
||||
import com.wrbug.polymarketbot.util.parseStringArray
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -207,6 +208,36 @@ class MarketService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 tokenId 从 Gamma 解析市场信息(conditionId、outcomeIndex)
|
||||
* 用于链上解析时 Gamma 失败、仅带 tokenId 的交易在 processBuyTrade 中补查市场
|
||||
*/
|
||||
suspend fun getMarketInfoByTokenId(tokenId: String): MarketInfoByTokenId? {
|
||||
if (tokenId.isBlank()) return null
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.listMarkets(
|
||||
conditionIds = null,
|
||||
clobTokenIds = listOf(tokenId),
|
||||
includeTag = null
|
||||
)
|
||||
if (!response.isSuccessful || response.body().isNullOrEmpty()) return null
|
||||
val market = response.body()!!.first()
|
||||
val conditionId = market.conditionId ?: return null
|
||||
val clobTokenIdsRaw = market.clobTokenIds ?: market.clob_token_ids
|
||||
val clobTokenIds = (clobTokenIdsRaw ?: "").parseStringArray()
|
||||
val outcomeIndex = clobTokenIds.indexOfFirst { it.equals(tokenId, ignoreCase = true) }.takeIf { it >= 0 }
|
||||
?: return null
|
||||
val outcomes = market.outcomes.parseStringArray()
|
||||
val outcome = if (outcomeIndex < outcomes.size) outcomes[outcomeIndex] else null
|
||||
saveMarketFromResponse(conditionId, market)
|
||||
MarketInfoByTokenId(conditionId = conditionId, outcomeIndex = outcomeIndex, outcome = outcome)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("按 tokenId 查询市场失败: tokenId=$tokenId, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存(用于测试或手动刷新)
|
||||
*/
|
||||
@@ -230,5 +261,33 @@ class MarketService(
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 conditionId 查询该市场是否为 Neg Risk(需使用 Neg Risk Exchange 签约)
|
||||
* 用于跟单下单时选择正确的 exchange 合约,避免 invalid signature
|
||||
*/
|
||||
suspend fun getNegRiskByConditionId(conditionId: String): Boolean? {
|
||||
if (conditionId.isBlank()) return null
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.listMarkets(conditionIds = listOf(conditionId))
|
||||
if (!response.isSuccessful || response.body().isNullOrEmpty()) return null
|
||||
val marketResponse = response.body()!!.first()
|
||||
val fromEvent = marketResponse.events?.firstOrNull()?.negRisk
|
||||
val fromMarket = marketResponse.negRisk ?: marketResponse.negRiskOther
|
||||
fromEvent ?: fromMarket
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询市场 negRisk 失败: conditionId=$conditionId, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 tokenId 查询 Gamma 得到的市场信息(用于补全 trade.market / outcomeIndex)
|
||||
*/
|
||||
data class MarketInfoByTokenId(
|
||||
val conditionId: String,
|
||||
val outcomeIndex: Int,
|
||||
val outcome: String? = null
|
||||
)
|
||||
|
||||
+23
-33
@@ -3,12 +3,12 @@ package com.wrbug.polymarketbot.service.common
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
@@ -19,8 +19,6 @@ import retrofit2.converter.gson.GsonConverterFactory
|
||||
*/
|
||||
@Service
|
||||
class PolymarketApiKeyService(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
@@ -100,6 +98,24 @@ class PolymarketApiKeyService(
|
||||
creds.passphrase.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 CLOB /time 获取服务器时间戳,失败时返回 null(调用方使用本地时间)
|
||||
*/
|
||||
private suspend fun fetchServerTimeOrNull(): Long? {
|
||||
return try {
|
||||
val timeApi = createUnauthenticatedApi()
|
||||
val timeResponse = timeApi.getServerTime()
|
||||
if (timeResponse.isSuccessful) {
|
||||
timeResponse.body()?.string()?.trim()?.toLongOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取服务器时间失败,使用本地时间", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的 API Key
|
||||
*/
|
||||
@@ -109,20 +125,7 @@ class PolymarketApiKeyService(
|
||||
chainId: Long
|
||||
): Result<ApiKeyCreds> {
|
||||
return try {
|
||||
// 获取服务器时间(可选,用于更准确的时间戳)
|
||||
val serverTime = try {
|
||||
val timeApi = createUnauthenticatedApi()
|
||||
val timeResponse = timeApi.getServerTime()
|
||||
if (timeResponse.isSuccessful && timeResponse.body() != null) {
|
||||
timeResponse.body()!!.timestamp
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取服务器时间失败,使用本地时间", e)
|
||||
null
|
||||
}
|
||||
|
||||
val serverTime = fetchServerTimeOrNull()
|
||||
// 创建带 L1 认证的 API 客户端
|
||||
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
|
||||
|
||||
@@ -160,20 +163,7 @@ class PolymarketApiKeyService(
|
||||
chainId: Long
|
||||
): Result<ApiKeyCreds> {
|
||||
return try {
|
||||
// 获取服务器时间(可选)
|
||||
val serverTime = try {
|
||||
val timeApi = createUnauthenticatedApi()
|
||||
val timeResponse = timeApi.getServerTime()
|
||||
if (timeResponse.isSuccessful && timeResponse.body() != null) {
|
||||
timeResponse.body()!!.timestamp
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取服务器时间失败,使用本地时间", e)
|
||||
null
|
||||
}
|
||||
|
||||
val serverTime = fetchServerTimeOrNull()
|
||||
// 创建带 L1 认证的 API 客户端
|
||||
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
|
||||
|
||||
@@ -224,7 +214,7 @@ class PolymarketApiKeyService(
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
@@ -238,7 +228,7 @@ class PolymarketApiKeyService(
|
||||
val okHttpClient = createClient().build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
|
||||
+1
-1
@@ -401,7 +401,7 @@ class PolymarketClobService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取费率
|
||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||
|
||||
+87
-10
@@ -1,11 +1,13 @@
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData
|
||||
import com.wrbug.polymarketbot.dto.OrderPushMessage
|
||||
import com.wrbug.polymarketbot.dto.PositionPushMessage
|
||||
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
|
||||
import com.wrbug.polymarketbot.dto.WebSocketMessageType
|
||||
import com.wrbug.polymarketbot.service.accounts.PositionPushService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -38,28 +40,47 @@ class WebSocketSubscriptionService(
|
||||
// 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅)
|
||||
private val orderChannelCallbacks = ConcurrentHashMap<String, (OrderPushMessage) -> Unit>()
|
||||
|
||||
// 存储加密价差策略监控频道的订阅回调:sessionId -> (strategyId -> callback)
|
||||
private val monitorChannelCallbacks = ConcurrentHashMap<String, MutableMap<Long, (CryptoTailMonitorPushData) -> Unit>>()
|
||||
|
||||
// 加密价差策略监控服务(延迟注入,避免循环依赖)
|
||||
private var cryptoTailMonitorService: CryptoTailMonitorService? = null
|
||||
|
||||
/**
|
||||
* 设置加密价差策略监控服务(由 Spring 在初始化后调用)
|
||||
*/
|
||||
fun setCryptoTailMonitorService(service: CryptoTailMonitorService) {
|
||||
cryptoTailMonitorService = service
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册会话
|
||||
*/
|
||||
fun registerSession(sessionId: String, callback: (WsMessage) -> Unit) {
|
||||
sessionCallbacks[sessionId] = callback
|
||||
sessionSubscriptions[sessionId] = mutableSetOf()
|
||||
monitorChannelCallbacks[sessionId] = mutableMapOf()
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销会话
|
||||
*/
|
||||
fun unregisterSession(sessionId: String) {
|
||||
|
||||
// 取消所有订阅
|
||||
val channels = sessionSubscriptions.remove(sessionId) ?: emptySet()
|
||||
channels.forEach { channel ->
|
||||
unsubscribe(sessionId, channel)
|
||||
}
|
||||
|
||||
|
||||
// 清理 order 频道的回调
|
||||
orderChannelCallbacks.remove(sessionId)
|
||||
|
||||
|
||||
// 清理加密价差策略监控频道的回调
|
||||
val monitorCallbacks = monitorChannelCallbacks.remove(sessionId)
|
||||
monitorCallbacks?.keys?.forEach { strategyId ->
|
||||
cryptoTailMonitorService?.unsubscribe(sessionId, strategyId)
|
||||
}
|
||||
|
||||
sessionCallbacks.remove(sessionId)
|
||||
}
|
||||
|
||||
@@ -83,8 +104,8 @@ class WebSocketSubscriptionService(
|
||||
sendSubscribeAck(sessionId, channel, true)
|
||||
|
||||
// 根据频道类型启动推送服务
|
||||
when (channel) {
|
||||
"position" -> {
|
||||
when {
|
||||
channel == "position" -> {
|
||||
positionPushService.subscribe(sessionId) { message ->
|
||||
pushData(sessionId, channel, message)
|
||||
}
|
||||
@@ -97,7 +118,7 @@ class WebSocketSubscriptionService(
|
||||
}
|
||||
}
|
||||
}
|
||||
"order" -> {
|
||||
channel == "order" -> {
|
||||
// 订单推送:自动订阅所有启用的账户
|
||||
val callback: (OrderPushMessage) -> Unit = { message ->
|
||||
pushData(sessionId, channel, message)
|
||||
@@ -105,6 +126,20 @@ class WebSocketSubscriptionService(
|
||||
orderChannelCallbacks[sessionId] = callback
|
||||
orderPushService.subscribeAllEnabled(callback)
|
||||
}
|
||||
channel.startsWith("crypto_tail_monitor_") -> {
|
||||
// 加密价差策略监控频道
|
||||
val strategyId = channel.removePrefix("crypto_tail_monitor_").toLongOrNull()
|
||||
if (strategyId != null && cryptoTailMonitorService != null) {
|
||||
val callback: (CryptoTailMonitorPushData) -> Unit = { message ->
|
||||
pushData(sessionId, channel, message)
|
||||
}
|
||||
monitorChannelCallbacks.getOrPut(sessionId) { mutableMapOf() }[strategyId] = callback
|
||||
cryptoTailMonitorService!!.subscribe(sessionId, strategyId, callback)
|
||||
} else {
|
||||
logger.warn("无效的加密价差策略监控频道或服务未初始化: $channel")
|
||||
sendSubscribeAck(sessionId, channel, false, "无效的策略ID")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
logger.warn("未知的频道: $channel")
|
||||
sendSubscribeAck(sessionId, channel, false, "未知的频道")
|
||||
@@ -122,15 +157,58 @@ class WebSocketSubscriptionService(
|
||||
channelSubscriptions[channel]?.remove(sessionId)
|
||||
|
||||
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
|
||||
when (channel) {
|
||||
"position" -> positionPushService.unsubscribe(sessionId)
|
||||
"order" -> {
|
||||
when {
|
||||
channel == "position" -> positionPushService.unsubscribe(sessionId)
|
||||
channel == "order" -> {
|
||||
// 取消订阅所有账户的订单推送
|
||||
val callback = orderChannelCallbacks.remove(sessionId)
|
||||
if (callback != null) {
|
||||
orderPushService.unsubscribeAll(callback)
|
||||
}
|
||||
}
|
||||
channel.startsWith("crypto_tail_monitor_") -> {
|
||||
// 取消加密价差策略监控订阅
|
||||
val strategyId = channel.removePrefix("crypto_tail_monitor_").toLongOrNull()
|
||||
if (strategyId != null) {
|
||||
monitorChannelCallbacks[sessionId]?.remove(strategyId)
|
||||
cryptoTailMonitorService?.unsubscribe(sessionId, strategyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册加密价差策略监控回调(由 CryptoTailMonitorService 调用)
|
||||
*/
|
||||
fun registerMonitorCallback(sessionId: String, strategyId: Long, callback: (CryptoTailMonitorPushData) -> Unit) {
|
||||
monitorChannelCallbacks.getOrPut(sessionId) { mutableMapOf() }[strategyId] = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销加密价差策略监控回调(由 CryptoTailMonitorService 调用)
|
||||
*/
|
||||
fun unregisterMonitorCallback(sessionId: String, strategyId: Long) {
|
||||
monitorChannelCallbacks[sessionId]?.remove(strategyId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送加密价差策略监控数据(由 CryptoTailMonitorService 调用)
|
||||
*/
|
||||
fun pushMonitorData(strategyId: Long, data: CryptoTailMonitorPushData) {
|
||||
val channel = "crypto_tail_monitor_$strategyId"
|
||||
val sessionIds = channelSubscriptions[channel] ?: return
|
||||
|
||||
for (sessionId in sessionIds) {
|
||||
val callback = sessionCallbacks[sessionId]
|
||||
if (callback != null) {
|
||||
val message = WsMessage(
|
||||
type = WebSocketMessageType.DATA.value,
|
||||
channel = channel,
|
||||
payload = data,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
callback(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,4 +246,3 @@ class WebSocketSubscriptionService(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
-58
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.util.lt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.DateUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
@@ -45,7 +46,8 @@ class CopyTradingFilterService(
|
||||
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
||||
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
||||
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
||||
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
|
||||
marketEndDate: Long? = null, // 市场截止时间,用于市场截止时间检查
|
||||
outcomeIndex: Int? = null // 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||
): FilterResult {
|
||||
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
||||
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
||||
@@ -71,12 +73,20 @@ class CopyTradingFilterService(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查是否需要获取订单簿
|
||||
// 只有在配置了需要订单簿的过滤条件时才获取
|
||||
// 3. 检查是否需要获取订单簿或需要执行仓位检查
|
||||
// 只有在配置了需要订单簿的过滤条件时才获取订单簿
|
||||
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
|
||||
|
||||
// 3.5. 如果不需要订单簿,则跳过订单簿相关的检查,但仍然需要检查仓位限制
|
||||
if (!needOrderbook) {
|
||||
// 不需要订单簿,直接通过
|
||||
// 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||
if (copyOrderAmount != null && marketId != null) {
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||
if (!positionCheck.isPassed) {
|
||||
return positionCheck
|
||||
}
|
||||
}
|
||||
// 通过所有检查
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
@@ -108,7 +118,7 @@ class CopyTradingFilterService(
|
||||
|
||||
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||
if (copyOrderAmount != null && marketId != null) {
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||
if (!positionCheck.isPassed) {
|
||||
return positionCheck
|
||||
}
|
||||
@@ -193,12 +203,16 @@ class CopyTradingFilterService(
|
||||
|
||||
// 检查最低价格
|
||||
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
|
||||
return FilterResult.priceRangeFailed("价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
|
||||
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
|
||||
val minPriceStr = copyTrading.minPrice.stripTrailingZeros().toPlainString()
|
||||
return FilterResult.priceRangeFailed("价格低于最低限制: $priceStr < $minPriceStr")
|
||||
}
|
||||
|
||||
// 检查最高价格
|
||||
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
|
||||
return FilterResult.priceRangeFailed("价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
|
||||
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
|
||||
val maxPriceStr = copyTrading.maxPrice.stripTrailingZeros().toPlainString()
|
||||
return FilterResult.priceRangeFailed("价格高于最高限制: $priceStr > $maxPriceStr")
|
||||
}
|
||||
|
||||
return FilterResult.passed()
|
||||
@@ -236,7 +250,9 @@ class CopyTradingFilterService(
|
||||
val spread = bestAsk.subtract(bestBid)
|
||||
|
||||
if (spread.gt(copyTrading.maxSpread)) {
|
||||
return FilterResult.spreadFailed("价差过大: $spread > ${copyTrading.maxSpread}", orderbook)
|
||||
val spreadStr = spread.stripTrailingZeros().toPlainString()
|
||||
val maxSpreadStr = copyTrading.maxSpread.stripTrailingZeros().toPlainString()
|
||||
return FilterResult.spreadFailed("价差过大: $spreadStr > $maxSpreadStr", orderbook)
|
||||
}
|
||||
|
||||
return FilterResult.passed()
|
||||
@@ -276,94 +292,86 @@ class CopyTradingFilterService(
|
||||
val totalDepth = bidsDepth.add(asksDepth)
|
||||
|
||||
if (totalDepth.lt(copyTrading.minOrderDepth)) {
|
||||
return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
|
||||
val totalDepthStr = totalDepth.stripTrailingZeros().toPlainString()
|
||||
val minDepthStr = copyTrading.minOrderDepth.stripTrailingZeros().toPlainString()
|
||||
return FilterResult.orderDepthFailed("订单深度不足: $totalDepthStr < $minDepthStr", orderbook)
|
||||
}
|
||||
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查仓位限制(按市场检查)
|
||||
* 检查仓位限制(按市场+方向检查)
|
||||
* @param copyTrading 跟单配置
|
||||
* @param copyOrderAmount 跟单金额(USDC)
|
||||
* @param marketId 市场ID,用于过滤该市场的仓位
|
||||
* @param outcomeIndex 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||
* @return 过滤结果
|
||||
*/
|
||||
private suspend fun checkPositionLimits(
|
||||
copyTrading: CopyTrading,
|
||||
copyOrderAmount: BigDecimal,
|
||||
marketId: String
|
||||
marketId: String,
|
||||
outcomeIndex: Int?
|
||||
): FilterResult {
|
||||
// 如果未配置仓位限制,直接通过
|
||||
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
||||
if (copyTrading.maxPositionValue == null) {
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 获取账户的所有仓位信息
|
||||
val positionsResult = accountService.getAllPositions()
|
||||
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("获取仓位信息失败,无法进行仓位检查")
|
||||
}
|
||||
|
||||
|
||||
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
||||
|
||||
|
||||
// 过滤出当前账户且该市场的仓位
|
||||
val marketPositions = positions.currentPositions.filter {
|
||||
val marketPositions = positions.currentPositions.filter {
|
||||
it.accountId == copyTrading.accountId && it.marketId == marketId
|
||||
}
|
||||
|
||||
|
||||
// 检查最大仓位金额(如果配置了)
|
||||
if (copyTrading.maxPositionValue != null) {
|
||||
// 比较数据库成本价(本地订单记录)和外部持仓市值(可能来自其他终端的操作),取最大值
|
||||
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarket(copyTrading.id!!, marketId) ?: BigDecimal.ZERO
|
||||
val extValue = marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
||||
if (copyTrading.maxPositionValue != null && outcomeIndex != null) {
|
||||
// 按市场+方向(outcomeIndex)分别计算数据库成本价
|
||||
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarketAndOutcomeIndex(
|
||||
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 totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
||||
|
||||
|
||||
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
||||
val currentValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
|
||||
val dbValueStr = dbValue.stripTrailingZeros().toPlainString()
|
||||
val extValueStr = extValue.stripTrailingZeros().toPlainString()
|
||||
val orderAmountStr = copyOrderAmount.stripTrailingZeros().toPlainString()
|
||||
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
|
||||
val maxValueStr = copyTrading.maxPositionValue.stripTrailingZeros().toPlainString()
|
||||
return FilterResult.maxPositionValueFailed(
|
||||
"超过最大仓位金额限制: 当前该市场仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
||||
"超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentValueStr} USDC (DB=${dbValueStr}, Ext=${extValueStr}), 跟单金额=${orderAmountStr} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查最大仓位数量(如果配置了)
|
||||
if (copyTrading.maxPositionCount != null) {
|
||||
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
|
||||
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
|
||||
|
||||
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
|
||||
val extCount = positions.currentPositions
|
||||
.filter { it.accountId == copyTrading.accountId }
|
||||
.map { it.marketId }
|
||||
.distinct()
|
||||
.size
|
||||
|
||||
val currentPositionCount = maxOf(dbCount, extCount)
|
||||
|
||||
// 检查:如果当前没有该市场的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
||||
// 判断当前市场是否已有活跃仓位(数据库或外部持仓)
|
||||
val hasDbPosition = copyOrderTrackingRepository.existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(
|
||||
copyTrading.id, marketId, BigDecimal.ZERO
|
||||
)
|
||||
val hasExtPosition = marketPositions.isNotEmpty()
|
||||
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
|
||||
|
||||
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
|
||||
return FilterResult.maxPositionCountFailed(
|
||||
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return FilterResult.passed()
|
||||
} 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}")
|
||||
}
|
||||
@@ -394,8 +402,10 @@ class CopyTradingFilterService(
|
||||
val remainingTime = marketEndDate - currentTime
|
||||
|
||||
if (remainingTime > copyTrading.maxMarketEndDate) {
|
||||
val remainingTimeFormatted = DateUtils.formatDuration(remainingTime)
|
||||
val maxLimitFormatted = DateUtils.formatDuration(copyTrading.maxMarketEndDate)
|
||||
return FilterResult.marketEndDateFailed(
|
||||
"市场截止时间超出限制: 剩余时间=${remainingTime}ms (${remainingTime / (1000 * 60 * 60)}小时) > 最大限制=${copyTrading.maxMarketEndDate}ms (${copyTrading.maxMarketEndDate / (1000 * 60 * 60)}小时)"
|
||||
"市场截止时间超出限制: 剩余时间=${remainingTimeFormatted} > 最大限制=${maxLimitFormatted}"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+19
-18
@@ -14,6 +14,8 @@ import com.wrbug.polymarketbot.util.IllegalBigDecimal
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
@@ -30,10 +32,24 @@ class CopyTradingService(
|
||||
private val monitorService: CopyTradingMonitorService,
|
||||
private val jsonUtils: JsonUtils,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
) : ApplicationContextAware {
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建跟单配置
|
||||
* 支持两种方式:
|
||||
@@ -84,7 +100,6 @@ class CopyTradingService(
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
maxPositionCount = request.maxPositionCount,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = convertKeywordsToJson(request.keywords),
|
||||
maxMarketEndDate = request.maxMarketEndDate,
|
||||
@@ -116,7 +131,6 @@ class CopyTradingService(
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
maxPositionCount = request.maxPositionCount,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = convertKeywordsToJson(request.keywords),
|
||||
maxMarketEndDate = request.maxMarketEndDate,
|
||||
@@ -148,7 +162,6 @@ class CopyTradingService(
|
||||
minPrice = config.minPrice,
|
||||
maxPrice = config.maxPrice,
|
||||
maxPositionValue = config.maxPositionValue,
|
||||
maxPositionCount = config.maxPositionCount,
|
||||
keywordFilterMode = config.keywordFilterMode,
|
||||
keywords = config.keywords,
|
||||
configName = configName,
|
||||
@@ -266,16 +279,6 @@ class CopyTradingService(
|
||||
} else {
|
||||
copyTrading.maxPositionValue
|
||||
},
|
||||
// 处理 maxPositionCount:-1 表示要清空(设置为 null),null 表示不更新
|
||||
maxPositionCount = if (request.maxPositionCount != null) {
|
||||
if (request.maxPositionCount == -1) {
|
||||
null
|
||||
} else {
|
||||
request.maxPositionCount
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxPositionCount
|
||||
},
|
||||
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
|
||||
keywords = if (request.keywords != null) {
|
||||
convertKeywordsToJson(request.keywords)
|
||||
@@ -331,7 +334,7 @@ class CopyTradingService(
|
||||
*/
|
||||
@Transactional
|
||||
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
|
||||
return updateCopyTrading(
|
||||
return getSelf().updateCopyTrading(
|
||||
CopyTradingUpdateRequest(
|
||||
copyTradingId = request.copyTradingId,
|
||||
enabled = request.enabled
|
||||
@@ -504,7 +507,6 @@ class CopyTradingService(
|
||||
minPrice = copyTrading.minPrice?.toPlainString(),
|
||||
maxPrice = copyTrading.maxPrice?.toPlainString(),
|
||||
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
|
||||
maxPositionCount = copyTrading.maxPositionCount,
|
||||
keywordFilterMode = copyTrading.keywordFilterMode,
|
||||
keywords = convertJsonToKeywords(copyTrading.keywords),
|
||||
configName = copyTrading.configName,
|
||||
@@ -569,7 +571,6 @@ class CopyTradingService(
|
||||
val minPrice: BigDecimal?,
|
||||
val maxPrice: BigDecimal?,
|
||||
val maxPositionValue: BigDecimal?,
|
||||
val maxPositionCount: Int?,
|
||||
val keywordFilterMode: String,
|
||||
val keywords: String?, // JSON 字符串
|
||||
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
|
||||
|
||||
-8
@@ -20,8 +20,6 @@ enum class FilterStatus {
|
||||
FAILED_ORDER_DEPTH,
|
||||
/** 失败:超过最大仓位金额 */
|
||||
FAILED_MAX_POSITION_VALUE,
|
||||
/** 失败:超过最大仓位数量 */
|
||||
FAILED_MAX_POSITION_COUNT,
|
||||
/** 失败:关键字过滤 */
|
||||
FAILED_KEYWORD_FILTER,
|
||||
/** 失败:市场截止时间超出限制 */
|
||||
@@ -87,12 +85,6 @@ data class FilterResult(
|
||||
status = FilterStatus.FAILED_MAX_POSITION_VALUE,
|
||||
reason = reason
|
||||
)
|
||||
|
||||
/** 超过最大仓位数量 */
|
||||
fun maxPositionCountFailed(reason: String) = FilterResult(
|
||||
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
|
||||
reason = reason
|
||||
)
|
||||
|
||||
/** 关键字过滤失败 */
|
||||
fun keywordFilterFailed(reason: String) = FilterResult(
|
||||
|
||||
+48
-6
@@ -3,12 +3,15 @@ package com.wrbug.polymarketbot.service.copytrading.leaders
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import com.wrbug.polymarketbot.util.CategoryValidator
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* Leader 管理服务
|
||||
@@ -17,9 +20,11 @@ import org.springframework.transaction.annotation.Transactional
|
||||
class LeaderService(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyTradingRepository: CopyTradingRepository
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
|
||||
|
||||
/**
|
||||
@@ -154,7 +159,8 @@ class LeaderService(
|
||||
|
||||
val leaderDtos = leaders.map { leader ->
|
||||
val copyTradingCount = copyTradingRepository.countByLeaderId(leader.id!!)
|
||||
toDto(leader, copyTradingCount)
|
||||
val backtestCount = backtestTaskRepository.findByLeaderId(leader.id).size.toLong()
|
||||
toDto(leader, copyTradingCount, backtestCount)
|
||||
}
|
||||
|
||||
Result.success(
|
||||
@@ -176,19 +182,54 @@ class LeaderService(
|
||||
return try {
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
|
||||
|
||||
|
||||
val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId)
|
||||
Result.success(toDto(leader, copyTradingCount))
|
||||
val backtestCount = backtestTaskRepository.findByLeaderId(leaderId).size.toLong()
|
||||
Result.success(toDto(leader, copyTradingCount, backtestCount))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 Leader 详情失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Leader 余额
|
||||
* 使用代理地址查询 USDC 余额和持仓信息
|
||||
*/
|
||||
fun getLeaderBalance(leaderId: Long): Result<LeaderBalanceResponse> {
|
||||
return try {
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
|
||||
|
||||
// Leader 的 leaderAddress 就是代理地址
|
||||
val walletAddress = leader.leaderAddress
|
||||
|
||||
// 使用通用方法查询余额
|
||||
val balanceResult = runBlocking {
|
||||
blockchainService.getWalletBalance(walletAddress)
|
||||
}
|
||||
|
||||
balanceResult.map { walletBalance: WalletBalanceResponse ->
|
||||
LeaderBalanceResponse(
|
||||
leaderId = leader.id!!,
|
||||
leaderAddress = leader.leaderAddress,
|
||||
leaderName = leader.leaderName,
|
||||
availableBalance = walletBalance.availableBalance,
|
||||
positionBalance = walletBalance.positionBalance,
|
||||
totalBalance = walletBalance.totalBalance,
|
||||
positions = walletBalance.positions
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 Leader 余额失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 DTO
|
||||
*/
|
||||
private fun toDto(leader: Leader, copyTradingCount: Long = 0): LeaderDto {
|
||||
private fun toDto(leader: Leader, copyTradingCount: Long = 0, backtestCount: Long = 0): LeaderDto {
|
||||
return LeaderDto(
|
||||
id = leader.id!!,
|
||||
leaderAddress = leader.leaderAddress,
|
||||
@@ -197,6 +238,7 @@ class LeaderService(
|
||||
remark = leader.remark,
|
||||
website = leader.website,
|
||||
copyTradingCount = copyTradingCount,
|
||||
backtestCount = backtestCount,
|
||||
createdAt = leader.createdAt,
|
||||
updatedAt = leader.updatedAt
|
||||
)
|
||||
|
||||
+2
-2
@@ -114,10 +114,10 @@ class AccountOnChainMonitorService(
|
||||
}
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result.isJsonNull) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 使用 Gson 解析 receipt JSON
|
||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||
|
||||
|
||||
+3
-4
@@ -9,8 +9,8 @@ import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -28,8 +28,7 @@ class CopyTradingWebSocketService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
|
||||
|
||||
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
|
||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
||||
private val websocketUrl: String = PolymarketConstants.USER_WS_URL
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
|
||||
@@ -214,7 +213,7 @@ class CopyTradingWebSocketService(
|
||||
// 处理交易
|
||||
scope.launch {
|
||||
try {
|
||||
copyOrderTrackingService.processTrade(leaderId, trade, "websocket")
|
||||
copyOrderTrackingService.processTrade(leaderId, trade, "activity-ws")
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||
}
|
||||
|
||||
+49
-27
@@ -1,5 +1,8 @@
|
||||
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.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
@@ -11,6 +14,7 @@ import okhttp3.OkHttpClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 链上 WebSocket 监听服务
|
||||
@@ -23,12 +27,17 @@ class OnChainWsService(
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||
private val leaderRepository: LeaderRepository
|
||||
) {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
|
||||
|
||||
|
||||
// 存储需要监听的Leader:leaderId -> Leader
|
||||
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
||||
|
||||
|
||||
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
|
||||
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
|
||||
.maximumSize(100)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* 启动链上 WebSocket 监听
|
||||
* 通过统一服务订阅所有 Leader
|
||||
@@ -40,14 +49,14 @@ class OnChainWsService(
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 更新 Leader 列表
|
||||
monitoredLeaders.clear()
|
||||
leaders.forEach { leader ->
|
||||
addLeader(leader)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 添加Leader监听
|
||||
* 通过统一服务订阅该 Leader 的地址
|
||||
@@ -57,17 +66,17 @@ class OnChainWsService(
|
||||
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val leaderId = leader.id!!
|
||||
|
||||
|
||||
// 如果已经在监听列表中,不重复添加
|
||||
if (monitoredLeaders.containsKey(leaderId)) {
|
||||
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
monitoredLeaders[leaderId] = leader
|
||||
|
||||
|
||||
// 通过统一服务订阅
|
||||
val subscriptionId = "LEADER_$leaderId"
|
||||
unifiedOnChainWsService.subscribe(
|
||||
@@ -79,40 +88,53 @@ class OnChainWsService(
|
||||
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 处理 Leader 的交易
|
||||
*/
|
||||
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
private suspend fun handleLeaderTransaction(
|
||||
leaderId: Long,
|
||||
txHash: String,
|
||||
httpClient: OkHttpClient,
|
||||
rpcApi: EthereumRpcApi
|
||||
) {
|
||||
val leader = monitoredLeaders[leaderId] ?: return
|
||||
|
||||
|
||||
// 根据 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}")
|
||||
|
||||
|
||||
try {
|
||||
// 获取交易 receipt
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionReceipt",
|
||||
params = listOf(txHash)
|
||||
)
|
||||
|
||||
|
||||
val receiptResponse = rpcApi.call(receiptRequest)
|
||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result is JsonNull) {
|
||||
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 使用 Gson 解析 receipt JSON
|
||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||
|
||||
|
||||
// 获取区块号和时间戳
|
||||
val blockNumber = receiptJson.get("blockNumber")?.asString
|
||||
val blockTimestamp = if (blockNumber != null) {
|
||||
@@ -120,7 +142,7 @@ class OnChainWsService(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
// 解析 receipt 中的 Transfer 日志
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: run {
|
||||
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
|
||||
@@ -128,7 +150,7 @@ class OnChainWsService(
|
||||
}
|
||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
||||
|
||||
|
||||
// 解析交易信息
|
||||
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||
txHash = txHash,
|
||||
@@ -138,7 +160,7 @@ class OnChainWsService(
|
||||
erc1155Transfers = erc1155Transfers,
|
||||
retrofitFactory = retrofitFactory
|
||||
)
|
||||
|
||||
|
||||
if (trade != null) {
|
||||
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
|
||||
// 调用 processTrade 处理交易
|
||||
@@ -154,21 +176,21 @@ class OnChainWsService(
|
||||
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 移除Leader监听
|
||||
* 取消该 Leader 的订阅
|
||||
*/
|
||||
fun removeLeader(leaderId: Long) {
|
||||
monitoredLeaders.remove(leaderId)
|
||||
|
||||
|
||||
// 通过统一服务取消订阅
|
||||
val subscriptionId = "LEADER_$leaderId"
|
||||
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
||||
|
||||
|
||||
logger.info("移除 Leader 监听: leaderId=$leaderId")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 停止监听
|
||||
*/
|
||||
@@ -180,7 +202,7 @@ class OnChainWsService(
|
||||
}
|
||||
monitoredLeaders.clear()
|
||||
}
|
||||
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
|
||||
+4
-3
@@ -220,10 +220,10 @@ object OnChainWsUtils {
|
||||
return null
|
||||
}
|
||||
|
||||
// 尝试通过 Gamma API 查询市场信息(通过 tokenId)
|
||||
// 尝试通过 Gamma API 查询市场信息(通过 tokenId);失败时仍保留链上 tokenId 供后续按 tokenId 补查市场
|
||||
val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory)
|
||||
|
||||
// 创建 TradeResponse
|
||||
// 创建 TradeResponse:tokenId 始终写入链上解析得到的 asset(与 CLOB 一致),便于 Gamma 失败时在 processBuyTrade 中按 tokenId 再查
|
||||
return TradeResponse(
|
||||
id = txHash,
|
||||
market = marketInfo?.conditionId ?: "",
|
||||
@@ -233,7 +233,8 @@ object OnChainWsUtils {
|
||||
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
|
||||
user = walletAddress,
|
||||
outcomeIndex = marketInfo?.outcomeIndex,
|
||||
outcome = marketInfo?.outcome
|
||||
outcome = marketInfo?.outcome,
|
||||
tokenId = asset.toString()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+184
-18
@@ -1,5 +1,7 @@
|
||||
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.dto.ActivityTradeMessage
|
||||
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.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Polymarket Activity WebSocket 监听服务
|
||||
* 通过订阅全局 activity 交易流,客户端过滤 Leader 地址,实现实时交易检测
|
||||
* 通过订阅全局 activity 交易流(trades + orders_matched),客户端过滤 Leader 地址,实现实时交易检测
|
||||
* 延迟 < 100ms,适合快速跟单场景
|
||||
*/
|
||||
@Service
|
||||
@@ -29,8 +32,7 @@ class PolymarketActivityWsService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
|
||||
|
||||
@Value("\${polymarket.websocket.activity.url:wss://ws-live-data.polymarket.com}")
|
||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
||||
private val websocketUrl: String = PolymarketConstants.ACTIVITY_WS_URL
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
@@ -40,10 +42,30 @@ class PolymarketActivityWsService(
|
||||
// 要监听的 Leader 地址集合(小写地址 -> leaderId)
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
logger.info("启动 Activity WebSocket 监听,监控 ${monitoredAddresses.size} 个 Leader 地址")
|
||||
logger.info("启动 Activity WebSocket 监听(trades + orders_matched),监控 ${monitoredAddresses.size} 个 Leader 地址")
|
||||
connectAndSubscribe()
|
||||
}
|
||||
|
||||
@@ -159,6 +181,7 @@ class PolymarketActivityWsService(
|
||||
* 订阅全局 activity
|
||||
* 根据 @polymarket/real-time-data-client 的协议格式
|
||||
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
|
||||
* 同时订阅 trades 和 orders_matched 两种类型
|
||||
*/
|
||||
private fun subscribeAllActivity() {
|
||||
val client = wsClient
|
||||
@@ -170,6 +193,7 @@ class PolymarketActivityWsService(
|
||||
try {
|
||||
// 根据 real-time-data-client 的协议格式
|
||||
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
|
||||
// 同时订阅 trades 和 orders_matched 两种类型
|
||||
val subscribeMessage = """
|
||||
{
|
||||
"action": "subscribe",
|
||||
@@ -177,6 +201,10 @@ class PolymarketActivityWsService(
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades"
|
||||
},
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "orders_matched"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -184,46 +212,154 @@ class PolymarketActivityWsService(
|
||||
|
||||
client.sendMessage(subscribeMessage)
|
||||
isSubscribed = true
|
||||
logger.info("Activity WebSocket 订阅成功(全局交易流)")
|
||||
// 重置最后一次收到 activity 消息的时间
|
||||
lastActivityTime = System.currentTimeMillis()
|
||||
// 启动 Activity 消息超时检测
|
||||
// startActivityTimeoutCheck()
|
||||
logger.info("Activity WebSocket 订阅成功(全局交易流: trades + orders_matched)")
|
||||
} catch (e: Exception) {
|
||||
logger.error("订阅 Activity WebSocket 失败", e)
|
||||
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) {
|
||||
try {
|
||||
totalMessagesProcessed++
|
||||
|
||||
// 处理 PONG 响应
|
||||
if (message.trim() == "PONG" || message.trim() == "pong") {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用扩展函数解析消息
|
||||
|
||||
// 快速预检查:检查是否包含监听地址
|
||||
// 绝大部分消息会在这一步被过滤掉,避免不必要的 JSON 解析
|
||||
if (!containsMonitoredAddress(message)) {
|
||||
return
|
||||
}
|
||||
logger.info("发现leader交易:${message}")
|
||||
// 使用扩展函数解析消息(只对包含监听地址的消息)
|
||||
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
||||
// 不是有效的 JSON 或格式不匹配,跳过
|
||||
logger.warn("无法解析为 ActivityTradeMessage,可能不是 activity 消息: ${message.take(200)}")
|
||||
logger.warn("无法解析为 ActivityTradeMessage: ${message.take(200)}")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是 activity trade 消息
|
||||
if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") {
|
||||
// 不是我们关心的消息,直接返回
|
||||
|
||||
jsonParseMessages++
|
||||
|
||||
// 检查是否是 activity 消息(trades 或 orders_matched)
|
||||
if (tradeMessage.topic != "activity" ||
|
||||
(tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 更新最后一次收到 activity 消息的时间(即使不是我们监听的 Leader 的交易)
|
||||
lastActivityTime = System.currentTimeMillis()
|
||||
|
||||
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 {
|
||||
// 没有交易者地址,跳过
|
||||
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是我们监听的 Leader
|
||||
|
||||
// 二次验证:确认地址匹配
|
||||
val normalizedAddress = traderAddress.lowercase()
|
||||
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
|
||||
return
|
||||
@@ -327,6 +463,7 @@ class PolymarketActivityWsService(
|
||||
// 使用 transactionHash 作为 trade ID,如果没有则生成 fallback ID
|
||||
val tradeId = payload.transactionHash ?: "${leaderId}_${System.currentTimeMillis()}_${asset.take(10)}"
|
||||
|
||||
// asset 即 CLOB 的 tokenId,必须写入 TradeResponse,跟单下单时用此 tokenId 请求订单簿/下单,否则会用 conditionId+outcomeIndex 链上重算,可能得到与 CLOB 不一致的 tokenId
|
||||
TradeResponse(
|
||||
id = tradeId,
|
||||
market = conditionId,
|
||||
@@ -336,7 +473,8 @@ class PolymarketActivityWsService(
|
||||
timestamp = timestamp,
|
||||
user = null, // Activity WS 中不需要
|
||||
outcomeIndex = outcomeIndex,
|
||||
outcome = outcome
|
||||
outcome = outcome,
|
||||
tokenId = asset
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("解析 Activity Trade 失败: ${e.message}", e)
|
||||
@@ -383,10 +521,13 @@ class PolymarketActivityWsService(
|
||||
*/
|
||||
fun stop() {
|
||||
logger.info("停止 Activity WebSocket 监听")
|
||||
stopActivityTimeoutCheck()
|
||||
wsClient?.closeConnection()
|
||||
wsClient = null
|
||||
isSubscribed = false
|
||||
monitoredAddresses.clear()
|
||||
processedTxHashes.invalidateAll() // 清空去重缓存
|
||||
lastActivityTime = 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -403,8 +544,33 @@ class PolymarketActivityWsService(
|
||||
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
|
||||
fun destroy() {
|
||||
logger.info("Activity WS 性能统计: ${getPerformanceStats()}")
|
||||
stop()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
+14
-2
@@ -129,12 +129,20 @@ class UnifiedOnChainWsService(
|
||||
}
|
||||
addressConnections.clear()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
* @return Map<address, isConnected>
|
||||
*/
|
||||
fun getConnectionStatuses(): Map<String, Boolean> {
|
||||
return addressConnections.mapValues { (_, connection) -> connection.isConnected() }
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
|
||||
}
|
||||
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
@@ -211,6 +219,10 @@ class UnifiedOnChainWsService(
|
||||
return subscriptions.isEmpty()
|
||||
}
|
||||
|
||||
fun isConnected(): Boolean {
|
||||
return isConnected
|
||||
}
|
||||
|
||||
private suspend fun startConnectionLoop() {
|
||||
while (scope.isActive) {
|
||||
try {
|
||||
|
||||
+20
-6
@@ -18,8 +18,14 @@ import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.util.div
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -41,8 +47,7 @@ class OrderPushService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
|
||||
|
||||
@Value("\${polymarket.rtds.ws-url}")
|
||||
private lateinit var polymarketWsUrl: String
|
||||
private val polymarketWsUrl: String = PolymarketConstants.RTDS_WS_URL
|
||||
|
||||
// 存储账户 ID 和对应的 WebSocket 连接
|
||||
private val accountConnections = ConcurrentHashMap<Long, PolymarketWebSocketClient>()
|
||||
@@ -426,20 +431,29 @@ class OrderPushService(
|
||||
// 获取市场信息(使用 MarketService,优先从数据库/缓存获取)
|
||||
val market = marketService.getMarket(conditionId ?: openOrder.market)
|
||||
|
||||
// 转换为 DTO
|
||||
// 有成交时按公式计算实际成交价:original_size * price / size_matched,数量用 size_matched
|
||||
val sizeMatched = openOrder.sizeMatched.toSafeBigDecimal()
|
||||
val avgFilledPrice = if (sizeMatched.gt(BigDecimal.ZERO)) {
|
||||
openOrder.originalSize.toSafeBigDecimal()
|
||||
.multi(openOrder.price)
|
||||
.div(sizeMatched, 18)
|
||||
} else null
|
||||
|
||||
// 转换为 DTO(展示数量用 size_matched)
|
||||
// 注意:createdAt 是 unix timestamp (Long),需要转换为字符串
|
||||
OrderDetailDto(
|
||||
id = openOrder.id,
|
||||
market = openOrder.market,
|
||||
side = openOrder.side,
|
||||
price = openOrder.price,
|
||||
size = openOrder.originalSize, // 使用 original_size
|
||||
filled = openOrder.sizeMatched, // 使用 size_matched
|
||||
size = openOrder.originalSize,
|
||||
filled = openOrder.sizeMatched, // 已成交数量用 size_matched
|
||||
status = openOrder.status,
|
||||
createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串
|
||||
marketName = market?.title,
|
||||
marketSlug = market?.slug, // 显示用的 slug
|
||||
marketIcon = market?.icon
|
||||
marketIcon = market?.icon,
|
||||
avgFilledPrice = avgFilledPrice?.toPlainString() // 实际成交价 = original_size*price/size_matched
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
|
||||
+152
-75
@@ -8,6 +8,7 @@ import org.web3j.crypto.Credentials
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* 订单签名服务
|
||||
@@ -19,11 +20,31 @@ import java.math.RoundingMode
|
||||
*/
|
||||
@Service
|
||||
class OrderSigningService {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderSigningService::class.java)
|
||||
|
||||
// Polygon 主网合约地址
|
||||
|
||||
/**
|
||||
* 根据是否为 Neg Risk 市场返回签约用 exchange 合约地址
|
||||
* @param negRisk true 时使用 Neg Risk CTF Exchange,否则使用标准 CTF Exchange
|
||||
*/
|
||||
fun getExchangeContract(negRisk: Boolean): String {
|
||||
return if (negRisk) NEG_RISK_EXCHANGE_CONTRACT else EXCHANGE_CONTRACT
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据钱包类型返回 CLOB 订单签名类型
|
||||
* @param walletType Magic=邮箱/社交登录, Safe=Web3 钱包
|
||||
* @return 1=POLY_PROXY(Magic), 2=POLY_GNOSIS_SAFE(Safe), 默认 2
|
||||
*/
|
||||
fun getSignatureTypeForWalletType(walletType: String?): Int {
|
||||
val walletTypeEnum = com.wrbug.polymarketbot.enums.WalletType.fromStringOrDefault(walletType, com.wrbug.polymarketbot.enums.WalletType.SAFE)
|
||||
return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2
|
||||
}
|
||||
|
||||
// Polygon 主网合约地址(标准 CTF Exchange)
|
||||
private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
|
||||
// Neg Risk CTF Exchange(neg risk 市场需用此合约签约,否则服务端返回 invalid signature)
|
||||
private val NEG_RISK_EXCHANGE_CONTRACT = "0xC5d563A36AE78145C45a50134d48A1215220f80a"
|
||||
private val CHAIN_ID = 137L
|
||||
|
||||
// USDC 有 6 位小数
|
||||
@@ -36,13 +57,11 @@ class OrderSigningService {
|
||||
size = 2,
|
||||
amount = 4
|
||||
)
|
||||
|
||||
// 金额精度限制(根据 Polymarket API 要求)
|
||||
// makerAmount (USDC) 最多 2 位小数
|
||||
// takerAmount (shares) 最多 4 位小数
|
||||
private val MAKER_AMOUNT_DECIMALS = 2 // USDC 金额精度
|
||||
private val TAKER_AMOUNT_DECIMALS = 4 // shares 数量精度
|
||||
|
||||
|
||||
// 价格有效范围(Polymarket API 要求)
|
||||
private val MIN_PRICE = BigDecimal("0.01")
|
||||
private val MAX_PRICE = BigDecimal("0.99")
|
||||
|
||||
/**
|
||||
* 订单金额计算结果
|
||||
*/
|
||||
@@ -62,7 +81,9 @@ class OrderSigningService {
|
||||
|
||||
/**
|
||||
* 计算订单金额(makerAmount 和 takerAmount)
|
||||
*
|
||||
*
|
||||
* 参考 clob-client/src/order-builder/helpers.ts 的 getOrderRawAmounts 函数
|
||||
*
|
||||
* @param side BUY 或 SELL
|
||||
* @param size 数量(shares)
|
||||
* @param price 价格(0-1 之间)
|
||||
@@ -77,49 +98,59 @@ class OrderSigningService {
|
||||
): OrderAmounts {
|
||||
val sizeDecimal = size.toSafeBigDecimal()
|
||||
val priceDecimal = price.toSafeBigDecimal()
|
||||
|
||||
// 对价格进行 roundNormal 处理(与 clob-client 保持一致)
|
||||
var rawPrice = roundNormal(priceDecimal, roundConfig.price)
|
||||
|
||||
// 验证价格范围,如果超出则调整到最接近的有效值
|
||||
// Polymarket API 要求: 0.01 <= price <= 0.99
|
||||
if (rawPrice > MAX_PRICE) {
|
||||
logger.warn("价格超出最大限制,已调整: $priceDecimal -> $MAX_PRICE")
|
||||
rawPrice = MAX_PRICE
|
||||
} else if (rawPrice < MIN_PRICE) {
|
||||
logger.warn("价格低于最小限制,已调整: $priceDecimal -> $MIN_PRICE")
|
||||
rawPrice = MIN_PRICE
|
||||
}
|
||||
|
||||
if (side.uppercase() == "BUY") {
|
||||
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
|
||||
// makerAmount 是 USDC 金额,最多 2 位小数
|
||||
// takerAmount 是 shares 数量,最多 4 位小数
|
||||
val rawTakerAmt = roundDown(sizeDecimal, roundConfig.size)
|
||||
|
||||
// makerAmount = price * size,使用原始价格计算(与SDK保持一致)
|
||||
// 先使用原始价格计算,然后再进行舍入,确保精度一致
|
||||
var rawMakerAmt = rawTakerAmt.multiply(priceDecimal)
|
||||
|
||||
// 确保 makerAmount 精度(USDC,最多 2 位小数)
|
||||
rawMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
|
||||
|
||||
// 确保 takerAmount 精度(shares,最多 4 位小数)
|
||||
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
|
||||
|
||||
// 参考 clob-client/src/order-builder/helpers.ts 第 73-89 行
|
||||
// 注意:Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
|
||||
// takerAmount (shares) 使用 4 位小数
|
||||
val rawTakerAmt = roundDown(sizeDecimal, 4)
|
||||
|
||||
var rawMakerAmt = rawTakerAmt.multiply(rawPrice)
|
||||
// makerAmount (USDC) 使用 2 位小数
|
||||
if (decimalPlaces(rawMakerAmt) > 2) {
|
||||
rawMakerAmt = roundUp(rawMakerAmt, 2 + 4)
|
||||
if (decimalPlaces(rawMakerAmt) > 2) {
|
||||
rawMakerAmt = roundDown(rawMakerAmt, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为 wei(6 位小数)
|
||||
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
|
||||
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
|
||||
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
|
||||
} else {
|
||||
// SELL: makerAmount = size (shares), takerAmount = price * size (USDC)
|
||||
// 根据 Polymarket API 要求:
|
||||
// - makerAmount (shares) 最多 2 位小数
|
||||
// - takerAmount (USDC) 最多 4 位小数
|
||||
// 参考 clob-client/src/order-builder/helpers.ts 第 90-105 行
|
||||
val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size)
|
||||
|
||||
// takerAmount = price * size,使用原始价格计算(不使用舍入后的价格)
|
||||
// SDK期望使用原始价格进行计算,以保留足够的精度
|
||||
// 例如:0.9596 * 16.09 = 15.439964,而不是 0.96 * 16.09 = 15.4464
|
||||
val rawTakerAmt = rawMakerAmt.multiply(priceDecimal)
|
||||
|
||||
// 确保 makerAmount 精度(shares,最多 2 位小数,符合 API 要求)
|
||||
val finalMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
|
||||
|
||||
// 确保 takerAmount 精度(USDC,最多 4 位小数,符合 API 要求)
|
||||
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
|
||||
|
||||
|
||||
var rawTakerAmt = rawMakerAmt.multiply(rawPrice)
|
||||
// 如果 takerAmount 的小数位数超过 roundConfig.amount,进行特殊舍入处理
|
||||
if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
|
||||
rawTakerAmt = roundUp(rawTakerAmt, roundConfig.amount + 4)
|
||||
if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
|
||||
rawTakerAmt = roundDown(rawTakerAmt, roundConfig.amount)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为 wei(6 位小数)
|
||||
val makerAmount = parseUnits(finalMakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
|
||||
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
|
||||
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
|
||||
}
|
||||
}
|
||||
@@ -137,6 +168,7 @@ class OrderSigningService {
|
||||
* @param nonce nonce(默认 "0")
|
||||
* @param feeRateBps 费率基点(默认 "0")
|
||||
* @param expiration 过期时间戳(秒,0 表示永不过期)
|
||||
* @param exchangeContract 签约用 exchange 合约地址;null 时用标准 CTF Exchange,neg risk 市场需传 Neg Risk Exchange
|
||||
* @return 签名的订单对象
|
||||
*/
|
||||
fun createAndSignOrder(
|
||||
@@ -149,7 +181,8 @@ class OrderSigningService {
|
||||
signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致)
|
||||
nonce: String = "0",
|
||||
feeRateBps: String = "0",
|
||||
expiration: String = "0"
|
||||
expiration: String = "0",
|
||||
exchangeContract: String? = null
|
||||
): SignedOrderObject {
|
||||
try {
|
||||
// 1. 从私钥获取签名地址
|
||||
@@ -182,10 +215,11 @@ class OrderSigningService {
|
||||
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
|
||||
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
|
||||
|
||||
// 6. 构建订单数据并签名
|
||||
// 6. 构建订单数据并签名(neg risk 市场需用 NEG_RISK_EXCHANGE_CONTRACT)
|
||||
val contract = exchangeContract?.takeIf { it.isNotBlank() } ?: EXCHANGE_CONTRACT
|
||||
val signature = signOrder(
|
||||
privateKey = privateKey,
|
||||
exchangeContract = EXCHANGE_CONTRACT,
|
||||
exchangeContract = contract,
|
||||
chainId = CHAIN_ID,
|
||||
salt = salt,
|
||||
maker = makerAddressLower,
|
||||
@@ -247,20 +281,20 @@ class OrderSigningService {
|
||||
signatureType: Int
|
||||
): String {
|
||||
try {
|
||||
// 1. 从私钥创建 BigInteger
|
||||
// 1. 私钥与密钥对
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
|
||||
// 2. 编码域分隔符
|
||||
val credentials = Credentials.create(privateKeyBigInt.toString(16))
|
||||
val ecKeyPair = credentials.ecKeyPair
|
||||
|
||||
// 2. 编码域分隔符(verifyingContract 显式小写,与 EIP-712 约定一致)
|
||||
val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain(
|
||||
chainId = chainId,
|
||||
verifyingContract = exchangeContract
|
||||
verifyingContract = exchangeContract.lowercase()
|
||||
)
|
||||
|
||||
|
||||
// 3. 编码订单消息哈希
|
||||
// signatureType 参数:1 = POLY_PROXY (代理钱包), 2 = POLY_GNOSIS_SAFE, 0 = EOA
|
||||
// 使用传入的 signatureType 参数,而不是硬编码
|
||||
// signatureType:1 = POLY_PROXY (Magic), 2 = POLY_GNOSIS_SAFE (Safe), 0 = EOA
|
||||
val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder(
|
||||
salt = salt,
|
||||
maker = maker,
|
||||
@@ -273,29 +307,25 @@ class OrderSigningService {
|
||||
nonce = nonce,
|
||||
feeRateBps = feeRateBps,
|
||||
side = side,
|
||||
signatureType = signatureType // 使用传入的参数
|
||||
signatureType = signatureType
|
||||
)
|
||||
|
||||
// 4. 计算完整的结构化数据哈希
|
||||
|
||||
// 4. 计算完整 EIP-712 结构化数据哈希
|
||||
val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
|
||||
domainSeparator = domainSeparator,
|
||||
messageHash = orderHash
|
||||
)
|
||||
|
||||
// 5. 使用私钥签名
|
||||
|
||||
// 5. 使用私钥签名(needToHash=false,对 32 字节 hash 直接签名)
|
||||
val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false)
|
||||
|
||||
// 6. 组合签名(r + s + v)
|
||||
|
||||
// 6. 组合 r + s + v
|
||||
val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0')
|
||||
val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
|
||||
val vBytes = signature.v as ByteArray
|
||||
val vInt = if (vBytes.isNotEmpty()) {
|
||||
vBytes[0].toInt() and 0xff
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val vHex = String.format("%02x", vInt)
|
||||
|
||||
val vBytes = signature.v
|
||||
val vInt = if (vBytes.isNotEmpty()) vBytes[0].toInt() and 0xff else 0
|
||||
val vHex = "%02x".format(vInt)
|
||||
|
||||
return "0x$rHex$sHex$vHex"
|
||||
} catch (e: Exception) {
|
||||
logger.error("订单签名失败", e)
|
||||
@@ -303,12 +333,17 @@ class OrderSigningService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 并发安全:确保同一毫秒内多次调用生成唯一 salt,避免 FIXED 模式预签双单等场景的 salt 碰撞 */
|
||||
private val saltSequence = AtomicLong(0)
|
||||
|
||||
/**
|
||||
* 生成 salt(使用时间戳,毫秒)
|
||||
* 与 TypeScript SDK 保持一致,使用时间戳作为 salt
|
||||
* 生成 salt(时间戳 + 自增序列,保证并发下唯一)
|
||||
* 兼容 Polymarket:salt 为 Long,时间戳主位 + 序列次位,与 TypeScript SDK 语义兼容
|
||||
*/
|
||||
private fun generateSalt(): Long {
|
||||
return System.currentTimeMillis()
|
||||
val now = System.currentTimeMillis()
|
||||
val seq = saltSequence.incrementAndGet() and 0x3FF
|
||||
return now * 1000 + seq
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,23 +359,65 @@ class OrderSigningService {
|
||||
|
||||
/**
|
||||
* 正常舍入(四舍五入)
|
||||
* 参考 clob-client/src/utilities.ts 的 roundNormal 函数
|
||||
* 只有当小数位数超过 decimals 时才进行舍入
|
||||
*
|
||||
* @param value 要舍入的数值
|
||||
* @param decimals 目标小数位数
|
||||
* @return 舍入后的数值
|
||||
*/
|
||||
private fun roundNormal(value: BigDecimal, decimals: Int): BigDecimal {
|
||||
if (decimalPlaces(value) <= decimals) {
|
||||
return value
|
||||
}
|
||||
return value.setScale(decimals, RoundingMode.HALF_UP)
|
||||
}
|
||||
|
||||
/**
|
||||
* 向下舍入
|
||||
* 参考 clob-client/src/utilities.ts 的 roundDown 函数
|
||||
* 只有当小数位数超过 decimals 时才进行舍入
|
||||
*
|
||||
* @param value 要舍入的数值
|
||||
* @param decimals 目标小数位数
|
||||
* @return 舍入后的数值
|
||||
*/
|
||||
private fun roundDown(value: BigDecimal, decimals: Int): BigDecimal {
|
||||
if (decimalPlaces(value) <= decimals) {
|
||||
return value
|
||||
}
|
||||
return value.setScale(decimals, RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 向上舍入
|
||||
* 参考 clob-client/src/utilities.ts 的 roundUp 函数
|
||||
* 只有当小数位数超过 decimals 时才进行舍入
|
||||
*
|
||||
* @param value 要舍入的数值
|
||||
* @param decimals 目标小数位数
|
||||
* @return 舍入后的数值
|
||||
*/
|
||||
private fun roundUp(value: BigDecimal, decimals: Int): BigDecimal {
|
||||
if (decimalPlaces(value) <= decimals) {
|
||||
return value
|
||||
}
|
||||
return value.setScale(decimals, RoundingMode.UP)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 BigDecimal 的小数位数
|
||||
* 参考 clob-client/src/utilities.ts 的 decimalPlaces 函数
|
||||
*
|
||||
* @param value 要计算的数值
|
||||
* @return 小数位数
|
||||
*/
|
||||
private fun decimalPlaces(value: BigDecimal): Int {
|
||||
if (value.scale() <= 0) {
|
||||
return 0
|
||||
}
|
||||
// 去除尾部的零,获取真实的小数位数
|
||||
return value.stripTrailingZeros().scale()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+131
-57
@@ -23,6 +23,8 @@ import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
@@ -51,12 +53,26 @@ open class CopyOrderTrackingService(
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val marketService: MarketService, // 市场信息服务
|
||||
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
||||
) {
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
|
||||
|
||||
// 协程作用域(用于异步发送通知)
|
||||
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锁定)
|
||||
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||
@@ -138,10 +154,11 @@ open class CopyOrderTrackingService(
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
|
||||
// 2. 处理交易逻辑
|
||||
// 2. 处理交易逻辑(通过代理对象调用,确保 @Transactional 生效)
|
||||
val self = getSelf()
|
||||
val result = when (trade.side.uppercase()) {
|
||||
"BUY" -> processBuyTrade(leaderId, trade)
|
||||
"SELL" -> processSellTrade(leaderId, trade)
|
||||
"BUY" -> self.processBuyTrade(leaderId, trade, source)
|
||||
"SELL" -> self.processSellTrade(leaderId, trade)
|
||||
else -> {
|
||||
logger.warn("未知的交易方向: ${trade.side}")
|
||||
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||
@@ -168,6 +185,7 @@ open class CopyOrderTrackingService(
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
|
||||
} catch (e: Exception) {
|
||||
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
|
||||
if (isUniqueConstraintViolation(e)) {
|
||||
@@ -213,7 +231,7 @@ open class CopyOrderTrackingService(
|
||||
* 创建跟单买入订单并记录到跟踪表
|
||||
*/
|
||||
@Transactional
|
||||
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse): Result<Unit> {
|
||||
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
|
||||
return try {
|
||||
// 1. 查找所有启用且支持该Leader的跟单关系
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
@@ -240,19 +258,36 @@ open class CopyOrderTrackingService(
|
||||
continue
|
||||
}
|
||||
|
||||
// 直接使用outcomeIndex获取tokenId(支持多元市场)
|
||||
if (trade.outcomeIndex == null) {
|
||||
logger.warn("交易缺少outcomeIndex,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}")
|
||||
continue
|
||||
// 获取 tokenId:优先使用链上解析得到的 tokenId(与 Gamma clobTokenIds 一致),否则用 conditionId+outcomeIndex 链上重算
|
||||
val tokenId = if (!trade.tokenId.isNullOrBlank()) {
|
||||
trade.tokenId
|
||||
} else {
|
||||
if (trade.outcomeIndex == null) {
|
||||
logger.warn("交易缺少outcomeIndex且无tokenId,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}")
|
||||
continue
|
||||
}
|
||||
val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
continue
|
||||
}
|
||||
tokenIdResult.getOrNull() ?: continue
|
||||
}
|
||||
|
||||
// 获取tokenId(直接使用outcomeIndex,不转换为YES/NO)
|
||||
val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
// 当链上解析时 Gamma 失败导致 market/outcomeIndex 为空时,按 tokenId 补查市场信息
|
||||
var effectiveMarketId = trade.market
|
||||
var effectiveOutcomeIndex = trade.outcomeIndex
|
||||
if (effectiveMarketId.isBlank() && !trade.tokenId.isNullOrBlank()) {
|
||||
val infoByToken = marketService.getMarketInfoByTokenId(trade.tokenId)
|
||||
if (infoByToken != null) {
|
||||
effectiveMarketId = infoByToken.conditionId
|
||||
effectiveOutcomeIndex = infoByToken.outcomeIndex
|
||||
}
|
||||
}
|
||||
if (effectiveMarketId.isBlank()) {
|
||||
logger.warn("无法确定市场(conditionId),跳过: tradeId=${trade.id}, tokenId=${trade.tokenId}")
|
||||
continue
|
||||
}
|
||||
val tokenId = tokenIdResult.getOrNull() ?: continue
|
||||
|
||||
// 先计算跟单金额(用于仓位检查)
|
||||
// 注意:这里先计算金额,即使后续被过滤也会记录
|
||||
@@ -275,7 +310,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
if (needMarketInfo) {
|
||||
try {
|
||||
val market = marketService.getMarket(trade.market)
|
||||
val market = marketService.getMarket(effectiveMarketId)
|
||||
marketTitle = market?.title
|
||||
marketEndDate = market?.endDate
|
||||
} catch (e: Exception) {
|
||||
@@ -285,7 +320,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 过滤条件检查(在计算订单参数之前)
|
||||
// 传入 Leader 交易价格,用于价格区间检查
|
||||
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
|
||||
// 传入跟单金额和市场ID,用于仓位检查(按市场+方向检查仓位)
|
||||
// 传入市场标题,用于关键字过滤
|
||||
// 传入市场截止时间,用于市场截止时间检查
|
||||
// 订单簿只请求一次,返回给后续逻辑使用
|
||||
@@ -294,9 +329,10 @@ open class CopyOrderTrackingService(
|
||||
tokenId,
|
||||
tradePrice = tradePrice,
|
||||
copyOrderAmount = copyOrderAmount,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketTitle = marketTitle,
|
||||
marketEndDate = marketEndDate
|
||||
marketEndDate = marketEndDate,
|
||||
outcomeIndex = effectiveOutcomeIndex
|
||||
)
|
||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||
if (!filterResult.isPassed) {
|
||||
@@ -306,8 +342,8 @@ open class CopyOrderTrackingService(
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取市场信息(标题和slug)
|
||||
val market = marketService.getMarket(trade.market)
|
||||
val marketTitle = market?.title ?: trade.market
|
||||
val market = marketService.getMarket(effectiveMarketId)
|
||||
val marketTitle = market?.title ?: effectiveMarketId
|
||||
val marketSlug = market?.slug // 显示用的 slug
|
||||
|
||||
// 从过滤结果中提取 filterType
|
||||
@@ -327,11 +363,11 @@ open class CopyOrderTrackingService(
|
||||
accountId = copyTrading.accountId,
|
||||
leaderId = copyTrading.leaderId,
|
||||
leaderTradeId = trade.id,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketTitle = marketTitle,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcomeIndex = trade.outcomeIndex,
|
||||
outcomeIndex = effectiveOutcomeIndex,
|
||||
outcome = trade.outcome,
|
||||
price = trade.price.toSafeBigDecimal(),
|
||||
size = trade.size.toSafeBigDecimal(),
|
||||
@@ -357,7 +393,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
telegramNotificationService?.sendOrderFilteredNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcome = trade.outcome,
|
||||
@@ -537,6 +573,11 @@ open class CopyOrderTrackingService(
|
||||
|
||||
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity, baseFee=$feeRateBps")
|
||||
|
||||
// Neg Risk 市场需用 Neg Risk Exchange 签约,否则服务端返回 invalid signature
|
||||
val negRisk = marketService.getNegRiskByConditionId(effectiveMarketId) == true
|
||||
val exchangeContract = orderSigningService.getExchangeContract(negRisk)
|
||||
if (negRisk) logger.debug("市场为 Neg Risk,使用 Neg Risk Exchange 签约: conditionId=$effectiveMarketId")
|
||||
|
||||
// 调用API创建订单(带重试机制)
|
||||
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
|
||||
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
|
||||
@@ -544,6 +585,8 @@ open class CopyOrderTrackingService(
|
||||
clobApi = clobApi,
|
||||
privateKey = decryptedPrivateKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
walletAddress = account.walletAddress,
|
||||
exchangeContract = exchangeContract,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = buyPrice.toString(),
|
||||
@@ -551,7 +594,8 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
tradeId = trade.id,
|
||||
feeRateBps = feeRateBps
|
||||
feeRateBps = feeRateBps,
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
// 处理订单创建失败
|
||||
@@ -565,8 +609,8 @@ open class CopyOrderTrackingService(
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取市场信息(标题和slug)
|
||||
val market = marketService.getMarket(trade.market)
|
||||
val marketTitle = market?.title ?: trade.market
|
||||
val market = marketService.getMarket(effectiveMarketId)
|
||||
val marketTitle = market?.title ?: effectiveMarketId
|
||||
val marketSlug = market?.eventSlug // 跳转用的 slug
|
||||
|
||||
// 获取当前语言设置(从 LocaleContextHolder)
|
||||
@@ -578,7 +622,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
telegramNotificationService?.sendOrderFailureNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcome = null, // 失败时可能没有 outcome
|
||||
@@ -612,9 +656,9 @@ open class CopyOrderTrackingService(
|
||||
copyTradingId = copyTrading.id,
|
||||
accountId = copyTrading.accountId,
|
||||
leaderId = copyTrading.leaderId,
|
||||
marketId = trade.market,
|
||||
side = trade.outcomeIndex.toString(), // 使用outcomeIndex作为side(兼容旧数据)
|
||||
outcomeIndex = trade.outcomeIndex, // 新增字段
|
||||
marketId = effectiveMarketId,
|
||||
side = effectiveOutcomeIndex?.toString() ?: "", // 使用outcomeIndex作为side(兼容旧数据)
|
||||
outcomeIndex = effectiveOutcomeIndex, // 新增字段
|
||||
buyOrderId = realOrderId, // 使用真实订单ID
|
||||
leaderBuyTradeId = trade.id,
|
||||
leaderBuyQuantity = trade.size.toSafeBigDecimal(), // 存储 Leader 买入数量(用于固定金额模式计算卖出比例)
|
||||
@@ -622,7 +666,8 @@ open class CopyOrderTrackingService(
|
||||
price = buyPrice, // 使用下单价格,临时值
|
||||
remainingQuantity = finalBuyQuantity,
|
||||
status = "filled",
|
||||
notificationSent = false // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
||||
notificationSent = false, // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
||||
source = source // 订单来源
|
||||
)
|
||||
|
||||
copyOrderTrackingRepository.save(tracking)
|
||||
@@ -685,8 +730,8 @@ open class CopyOrderTrackingService(
|
||||
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
||||
return when (copyTrading.copyMode) {
|
||||
"RATIO" -> {
|
||||
// 比例模式:Leader 数量 × (比例 / 100)
|
||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
// 比例模式:Leader 数量 × 比例倍数(copyRatio 已经是倍数值,如 1.3 表示 130%)
|
||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
"FIXED" -> {
|
||||
@@ -718,7 +763,7 @@ open class CopyOrderTrackingService(
|
||||
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
||||
?: run {
|
||||
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
||||
@@ -789,7 +834,7 @@ open class CopyOrderTrackingService(
|
||||
// 如果无法计算总比例(查询失败),使用默认比例
|
||||
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
||||
@@ -865,13 +910,13 @@ open class CopyOrderTrackingService(
|
||||
}
|
||||
|
||||
"RATIO" -> {
|
||||
// 比例模式:直接使用配置的 copyRatio (需要除以100)
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
// 比例模式:直接使用配置的 copyRatio(已经是倍数值,如 1.3 表示 130%)
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -883,13 +928,21 @@ open class CopyOrderTrackingService(
|
||||
finalNeedMatch = BigDecimal.ONE
|
||||
}
|
||||
|
||||
// 4. 获取tokenId(直接使用outcomeIndex,支持多元市场)
|
||||
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
return
|
||||
// 4. 获取 tokenId:优先使用链上解析得到的 tokenId,否则用 conditionId+outcomeIndex 链上重算
|
||||
val tokenId = if (!leaderSellTrade.tokenId.isNullOrBlank()) {
|
||||
leaderSellTrade.tokenId
|
||||
} else {
|
||||
if (leaderSellTrade.outcomeIndex == null) {
|
||||
logger.error("卖出交易缺少outcomeIndex且无tokenId: market=${leaderSellTrade.market}")
|
||||
return
|
||||
}
|
||||
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
return
|
||||
}
|
||||
tokenIdResult.getOrNull() ?: return
|
||||
}
|
||||
val tokenId = tokenIdResult.getOrNull() ?: return
|
||||
|
||||
// 5. 计算卖出价格(优先使用订单簿 bestBid,失败则使用 Leader 价格,固定按90%计算)
|
||||
// 注意:需要先计算卖出价格,因为后续创建 matchDetails 需要使用实际卖出价格
|
||||
@@ -974,7 +1027,12 @@ open class CopyOrderTrackingService(
|
||||
"0"
|
||||
}
|
||||
|
||||
// 9. 创建并签名卖出订单
|
||||
// 9. Neg Risk 市场需用 Neg Risk Exchange 签约
|
||||
val negRiskSell = marketService.getNegRiskByConditionId(leaderSellTrade.market) == true
|
||||
val exchangeContractSell = orderSigningService.getExchangeContract(negRiskSell)
|
||||
if (negRiskSell) logger.debug("卖出市场为 Neg Risk,使用 Neg Risk Exchange 签约: conditionId=${leaderSellTrade.market}")
|
||||
|
||||
// 10. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -983,17 +1041,18 @@ open class CopyOrderTrackingService(
|
||||
side = "SELL",
|
||||
price = sellPrice.toString(),
|
||||
size = totalMatched.toString(),
|
||||
signatureType = 2, // Browser Wallet
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
expiration = "0",
|
||||
exchangeContract = exchangeContractSell
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建并签名卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}", e)
|
||||
return
|
||||
}
|
||||
|
||||
// 10. 构建订单请求
|
||||
// 11. 构建订单请求
|
||||
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
|
||||
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
|
||||
val orderRequest = NewOrderRequest(
|
||||
@@ -1003,7 +1062,7 @@ open class CopyOrderTrackingService(
|
||||
deferExec = false
|
||||
)
|
||||
|
||||
// 11. 创建带认证的CLOB API客户端(使用解密后的凭证)
|
||||
// 12. 创建带认证的CLOB API客户端(使用解密后的凭证)
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey,
|
||||
apiSecret,
|
||||
@@ -1011,12 +1070,13 @@ open class CopyOrderTrackingService(
|
||||
account.walletAddress
|
||||
)
|
||||
|
||||
// 12. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名)
|
||||
|
||||
// 13. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名)
|
||||
val createOrderResult = createOrderWithRetry(
|
||||
clobApi = clobApi,
|
||||
privateKey = decryptedPrivateKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
walletAddress = account.walletAddress,
|
||||
exchangeContract = exchangeContractSell,
|
||||
tokenId = tokenId,
|
||||
side = "SELL",
|
||||
price = sellPrice.toString(),
|
||||
@@ -1024,7 +1084,8 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id,
|
||||
tradeId = leaderSellTrade.id,
|
||||
feeRateBps = feeRateBps
|
||||
feeRateBps = feeRateBps,
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
if (createOrderResult.isFailure) {
|
||||
@@ -1108,7 +1169,9 @@ open class CopyOrderTrackingService(
|
||||
*
|
||||
* @param clobApi CLOB API 客户端
|
||||
* @param privateKey 私钥(用于签名)
|
||||
* @param makerAddress 代理钱包地址
|
||||
* @param makerAddress 代理钱包地址(funder)
|
||||
* @param walletAddress 账户 EOA 地址(须与私钥推导的 signer 一致,用于校验及 POLY_ADDRESS)
|
||||
* @param exchangeContract 签约用 exchange 合约(Neg Risk 市场需用 Neg Risk Exchange)
|
||||
* @param tokenId Token ID
|
||||
* @param side 订单方向(BUY/SELL)
|
||||
* @param price 价格
|
||||
@@ -1117,12 +1180,15 @@ open class CopyOrderTrackingService(
|
||||
* @param copyTradingId 跟单配置ID(用于日志)
|
||||
* @param tradeId Leader 交易ID(用于日志)
|
||||
* @param feeRateBps 费率基点(从API动态获取)
|
||||
* @param signatureType 签名类型(1=Magic, 2=Safe)
|
||||
* @return 成功返回订单ID,失败返回异常
|
||||
*/
|
||||
private suspend fun createOrderWithRetry(
|
||||
clobApi: PolymarketClobApi,
|
||||
privateKey: String,
|
||||
makerAddress: String,
|
||||
walletAddress: String,
|
||||
exchangeContract: String,
|
||||
tokenId: String,
|
||||
side: String,
|
||||
price: String,
|
||||
@@ -1130,7 +1196,8 @@ open class CopyOrderTrackingService(
|
||||
owner: String,
|
||||
copyTradingId: Long,
|
||||
tradeId: String,
|
||||
feeRateBps: String
|
||||
feeRateBps: String,
|
||||
signatureType: Int
|
||||
): Result<String> {
|
||||
var lastError: Exception? = null
|
||||
|
||||
@@ -1145,12 +1212,20 @@ open class CopyOrderTrackingService(
|
||||
side = side,
|
||||
price = price,
|
||||
size = size,
|
||||
signatureType = 2, // Browser Wallet
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
expiration = "0",
|
||||
exchangeContract = exchangeContract
|
||||
)
|
||||
|
||||
// 校验 signer 与账户 walletAddress 一致,否则服务端会返回 invalid signature(POLY_ADDRESS 与 order.signer 需一致)
|
||||
if (signedOrder.signer.lowercase() != walletAddress.lowercase()) {
|
||||
val msg = "订单 signer 与账户 walletAddress 不一致,会导致 invalid signature。请确认该账户的私钥与 walletAddress 对应同一 EOA,且 API 密钥由该 EOA 创建。signer=${signedOrder.signer.take(10)}..., walletAddress=${walletAddress.take(10)}..."
|
||||
logger.error(msg)
|
||||
return Result.failure(IllegalStateException(msg))
|
||||
}
|
||||
|
||||
// 构建订单请求
|
||||
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
|
||||
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
|
||||
@@ -1411,7 +1486,6 @@ open class CopyOrderTrackingService(
|
||||
FilterStatus.FAILED_SPREAD -> "SPREAD"
|
||||
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
|
||||
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
|
||||
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
|
||||
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
|
||||
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user