Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83a415fb7e | |||
| 727fa7ed58 | |||
| 8ab8ae5205 | |||
| 0769022878 | |||
| 06a122dd34 | |||
| fb6b469e9f | |||
| 22a6544373 | |||
| 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 |
@@ -14,6 +14,10 @@
|
||||
│ └── package.json
|
||||
├── backend/ # 后端相关 skill
|
||||
└── common/ # 通用 skill
|
||||
└── create-release/ # 创建 GitHub Release
|
||||
├── SKILL.md
|
||||
└── scripts/
|
||||
└── create-release.sh
|
||||
```
|
||||
|
||||
- **SKILL.md**:YAML frontmatter(`name`、`description` 必填,`name` 须与父文件夹名一致、小写连字符)+ 给 Agent 的详细指令。
|
||||
@@ -27,7 +31,8 @@
|
||||
|
||||
## 示例
|
||||
|
||||
- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key。
|
||||
- `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
|
||||
|
||||
@@ -37,3 +42,17 @@ 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` 发布脚本
|
||||
@@ -346,4 +346,3 @@ main() {
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
|
||||
@@ -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
|
||||
@@ -6,14 +6,6 @@ on:
|
||||
- published # 当通过 GitHub Releases 页面创建 release 时触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_type:
|
||||
description: '构建类型'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- package-only # 只打包产物
|
||||
- package-and-docker # 打包产物 + Docker 镜像
|
||||
default: 'package-and-docker'
|
||||
version:
|
||||
description: '版本号(例如: v1.0.0)'
|
||||
required: false
|
||||
@@ -36,22 +28,6 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
|
||||
|
||||
- name: Determine build type
|
||||
id: build_config
|
||||
run: |
|
||||
# 确定构建类型
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
# Release 事件:默认只打包产物(不构建 Docker)
|
||||
BUILD_TYPE="package-only"
|
||||
echo "📦 Release 事件:将只打包产物(不构建 Docker)"
|
||||
else
|
||||
# workflow_dispatch 事件:使用用户输入
|
||||
BUILD_TYPE="${{ github.event.inputs.build_type }}"
|
||||
echo "🔧 手动触发:构建类型 = ${BUILD_TYPE}"
|
||||
fi
|
||||
|
||||
echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract version and check if pre-release
|
||||
id: extract_version
|
||||
run: |
|
||||
@@ -100,53 +76,6 @@ jobs:
|
||||
echo "📦 这是正式版本: $TAG_NAME"
|
||||
fi
|
||||
|
||||
- name: Send Telegram notification (build started)
|
||||
if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
run: |
|
||||
# 检查必要的环境变量
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 获取构建信息
|
||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
MESSAGE="🔨 <b>Release 构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
|
||||
else
|
||||
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
MESSAGE="🔨 <b>构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"
|
||||
fi
|
||||
|
||||
# 发送 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"
|
||||
# 通知失败不应该导致整个 job 失败
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
echo "❌ 发送 Telegram 消息时发生错误"
|
||||
# 通知失败不应该导致整个 job 失败
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ============ 编译前后端产物 ============
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
@@ -263,22 +192,66 @@ jobs:
|
||||
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 }}
|
||||
run: |
|
||||
# 检查必要的环境变量
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 获取构建信息
|
||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||
|
||||
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" \
|
||||
-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
|
||||
|
||||
# ============ Docker 构建 ============
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# 启用多架构构建支持
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Prepare Docker build context
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
run: |
|
||||
echo "📦 准备 Docker 构建上下文..."
|
||||
# 确保构建产物存在且可访问
|
||||
@@ -295,7 +268,7 @@ jobs:
|
||||
ls -lh backend/build/libs/*.jar
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
@@ -314,13 +287,8 @@ jobs:
|
||||
cache-from: type=registry,ref=wrbug/polyhermes:latest
|
||||
cache-to: type=inline
|
||||
|
||||
- name: Skip Docker build notice
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-only'
|
||||
run: |
|
||||
echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only)"
|
||||
echo "✅ 仅打包产物已完成"
|
||||
|
||||
- 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 }}
|
||||
@@ -335,25 +303,14 @@ jobs:
|
||||
# 获取构建信息
|
||||
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||
BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}"
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
|
||||
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
|
||||
MESSAGE="✅ <b>Release 构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
|
||||
else
|
||||
MESSAGE="✅ <b>Release 打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
|
||||
fi
|
||||
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 }}"
|
||||
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
|
||||
MESSAGE="✅ <b>构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
|
||||
else
|
||||
MESSAGE="✅ <b>打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
|
||||
fi
|
||||
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)
|
||||
@@ -371,11 +328,9 @@ jobs:
|
||||
echo "✅ Telegram 通知发送成功"
|
||||
else
|
||||
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||
# 构建成功,通知失败不应该导致整个 job 失败
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
echo "❌ 发送 Telegram 消息时发生错误"
|
||||
# 构建成功,通知失败不应该导致整个 job 失败
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
+3
-2
@@ -19,7 +19,7 @@ backend/gradle-app.setting
|
||||
backend/.gradle
|
||||
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
|
||||
# backend/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
polyhub/
|
||||
# Kotlin
|
||||
*.kt.bak
|
||||
*.class
|
||||
@@ -112,4 +112,5 @@ __pycache__/
|
||||
clob-client/
|
||||
builder-relayer-client/
|
||||
landing-page/
|
||||
|
||||
clob-client-v2/
|
||||
settings.local.json
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.*
|
||||
|
||||
@@ -72,7 +73,7 @@ interface PolymarketClobApi {
|
||||
*/
|
||||
@POST("/orders/batch")
|
||||
suspend fun createOrdersBatch(
|
||||
@Body request: CreateOrdersBatchRequest
|
||||
@Body request: List<NewOrderRequest>
|
||||
): Response<List<OrderResponse>>
|
||||
|
||||
/**
|
||||
@@ -164,31 +165,34 @@ interface PolymarketClobApi {
|
||||
|
||||
/**
|
||||
* 获取服务器时间
|
||||
* 端点: /time
|
||||
* 端点: /time 返回纯数字(Unix 时间戳),非 JSON
|
||||
*/
|
||||
@GET("/time")
|
||||
suspend fun getServerTime(): Response<ServerTimeResponse>
|
||||
suspend fun getServerTime(): Response<ResponseBody>
|
||||
}
|
||||
|
||||
// 请求和响应数据类
|
||||
|
||||
/**
|
||||
* 签名的订单对象(根据官方文档)
|
||||
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order
|
||||
* V2 签名的订单对象
|
||||
* EIP-712 签名字段: salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder
|
||||
* API payload 额外字段: taker, expiration (不在 EIP-712 签名中,但 API 请求需要)
|
||||
* 参考: clob-client-v2/src/types/ordersV2.ts NewOrderV2
|
||||
*/
|
||||
data class SignedOrderObject(
|
||||
val salt: Long, // random salt used to create unique order
|
||||
val maker: String, // maker address (funder)
|
||||
val signer: String, // signing address
|
||||
val taker: String, // taker address (operator)
|
||||
val taker: String, // taker address (zero address for public orders, NOT in EIP-712 signing)
|
||||
val tokenId: String, // ERC1155 token ID of conditional token being traded
|
||||
val makerAmount: String, // maximum amount maker is willing to spend
|
||||
val takerAmount: String, // minimum amount taker will pay the maker in return
|
||||
val expiration: String, // unix expiration timestamp
|
||||
val nonce: String, // maker's exchange nonce of the order is associated
|
||||
val feeRateBps: String, // fee rate basis points as required by the operator
|
||||
val side: String, // buy or sell enum index ("BUY" or "SELL")
|
||||
val signatureType: Int, // signature type enum index
|
||||
val timestamp: String, // order creation time in milliseconds (V2)
|
||||
val expiration: String, // expiration timestamp unix seconds, "0" = no expiration (NOT in EIP-712 signing)
|
||||
val metadata: String, // bytes32 metadata (V2)
|
||||
val builder: String, // bytes32 builder code (V2)
|
||||
val signature: String // hex encoded signature
|
||||
)
|
||||
|
||||
@@ -197,10 +201,11 @@ data class SignedOrderObject(
|
||||
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order
|
||||
*/
|
||||
data class NewOrderRequest(
|
||||
val order: SignedOrderObject, // signed object
|
||||
val order: SignedOrderObject, // V2 signed object
|
||||
val owner: String, // api key of order owner
|
||||
val orderType: String, // order type ("FOK", "GTC", "GTD", "FAK")
|
||||
val deferExec: Boolean = false // defer execution flag
|
||||
val deferExec: Boolean = false, // defer execution
|
||||
val postOnly: Boolean = false // post only (maker-only)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -211,6 +216,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,27 +227,17 @@ data class NewOrderResponse(
|
||||
val takingAmount: String? = null, // taking amount
|
||||
@SerializedName("makingAmount")
|
||||
val makingAmount: String? = null // making amount
|
||||
)
|
||||
|
||||
/**
|
||||
* 旧的订单请求格式(已废弃,保留用于兼容)
|
||||
* @deprecated 使用 NewOrderRequest 代替
|
||||
*/
|
||||
@Deprecated("使用 NewOrderRequest 代替,需要签名的订单对象")
|
||||
data class CreateOrderRequest(
|
||||
val market: String? = null, // condition ID(可选,如果提供tokenId则不需要)
|
||||
val token_id: String? = null, // token ID(可选,如果提供market则不需要)
|
||||
val side: String, // "BUY" or "SELL"
|
||||
val price: String,
|
||||
val size: String,
|
||||
val type: String = "LIMIT",
|
||||
val expiration: Long? = null
|
||||
)
|
||||
|
||||
@Deprecated("使用 NewOrderRequest 代替")
|
||||
data class CreateOrdersBatchRequest(
|
||||
val orders: List<NewOrderRequest>
|
||||
)
|
||||
) {
|
||||
/**
|
||||
* 获取错误信息的便捷方法
|
||||
* 优先返回 errorMsg,其次返回 error,最后返回默认消息
|
||||
*/
|
||||
fun getErrorMessage(): String {
|
||||
return errorMsg?.takeIf { it.isNotBlank() }
|
||||
?: error?.takeIf { it.isNotBlank() }
|
||||
?: "创建订单失败"
|
||||
}
|
||||
}
|
||||
|
||||
data class CancelOrdersBatchRequest(
|
||||
val orderIds: List<String>
|
||||
@@ -334,7 +330,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 +360,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)
|
||||
}
|
||||
}
|
||||
@@ -44,5 +44,14 @@ object PolymarketConstants {
|
||||
* 用于 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"
|
||||
}
|
||||
|
||||
|
||||
+178
-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))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户详情
|
||||
*/
|
||||
@@ -449,5 +572,53 @@ class AccountController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 USDC.e wrap 为 pUSD(V2 迁移)
|
||||
*/
|
||||
@PostMapping("/wrap-to-pusd")
|
||||
fun wrapToPusd(@RequestBody request: Map<String, Any>): ResponseEntity<ApiResponse<Map<String, String?>>> {
|
||||
return try {
|
||||
val accountId = (request["accountId"] as? Number)?.toLong()
|
||||
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||
val result = runBlocking { accountService.wrapUsdcToPusd(accountId) }
|
||||
result.fold(
|
||||
onSuccess = { txHash ->
|
||||
ResponseEntity.ok(ApiResponse.success(mapOf("transactionHash" to txHash)))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("USDC.e → pUSD wrap 异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 USDC.e 余额(V2 迁移用)
|
||||
*/
|
||||
@PostMapping("/usdce-balance")
|
||||
fun getUsdceBalance(@RequestBody request: Map<String, Any>): ResponseEntity<ApiResponse<Map<String, String>>> {
|
||||
return try {
|
||||
val accountId = (request["accountId"] as? Number)?.toLong()
|
||||
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||
val result = runBlocking { accountService.getUsdceBalance(accountId) }
|
||||
result.fold(
|
||||
onSuccess = { balance ->
|
||||
ResponseEntity.ok(ApiResponse.success(mapOf("balance" to balance.toPlainString())))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询 USDC.e 余额失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 USDC.e 余额异常: ${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" -> 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
|
||||
)
|
||||
|
||||
|
||||
+28
@@ -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.ApiHealthCheckService
|
||||
import com.wrbug.polymarketbot.service.system.GeoblockService
|
||||
import com.wrbug.polymarketbot.service.system.ProxyConfigService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.*
|
||||
class ProxyConfigController(
|
||||
private val proxyConfigService: ProxyConfigService,
|
||||
private val apiHealthCheckService: ApiHealthCheckService,
|
||||
private val geoblockService: GeoblockService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
@@ -123,6 +125,32 @@ class ProxyConfigController(
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "API 健康检查失败:${e.message}", messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查交易服务器出口 IP 的地域限制(Polymarket Geoblock)
|
||||
*/
|
||||
@PostMapping("/geoblock-check")
|
||||
fun checkGeoblock(): ResponseEntity<ApiResponse<GeoblockCheckDto>> {
|
||||
return try {
|
||||
val result = runBlocking { geoblockService.checkGeoblock() }
|
||||
if (result.isSuccess) {
|
||||
ResponseEntity.ok(ApiResponse.success(result.getOrNull()))
|
||||
} else {
|
||||
val error = result.exceptionOrNull()
|
||||
logger.error("Geoblock 检查失败", error)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
error?.message ?: "Geoblock 检查失败",
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Geoblock 检查异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "Geoblock 检查失败:${e.message}", messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 // 自动赎回(系统级别配置,默认开启)
|
||||
)
|
||||
|
||||
|
||||
@@ -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 // 设置状态检查结果(可选)
|
||||
)
|
||||
@@ -22,6 +22,8 @@ data class BacktestCreateRequest(
|
||||
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开始)
|
||||
)
|
||||
|
||||
@@ -166,7 +168,9 @@ data class BacktestConfigDto(
|
||||
val supportSell: Boolean,
|
||||
val keywordFilterMode: String?,
|
||||
val keywords: List<String>?,
|
||||
val maxPositionValue: String?
|
||||
val maxPositionValue: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? // 最高价格(可选),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()
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* Polymarket Geoblock API 原始响应
|
||||
*/
|
||||
data class PolymarketGeoblockApiResponse(
|
||||
val blocked: Boolean = false,
|
||||
val ip: String = "",
|
||||
val country: String = "",
|
||||
val region: String = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* 地域限制检查结果(返回给前端)
|
||||
*/
|
||||
data class GeoblockCheckDto(
|
||||
val blocked: Boolean,
|
||||
val ip: String,
|
||||
val country: String,
|
||||
val region: String,
|
||||
val checkedAt: Long,
|
||||
val source: String = "server"
|
||||
)
|
||||
@@ -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(有成交时使用)
|
||||
)
|
||||
|
||||
|
||||
@@ -36,6 +36,18 @@ data class SubscriptionProxyConfigRequest(
|
||||
val type: String // CLASH 或 SS
|
||||
)
|
||||
|
||||
/**
|
||||
* 代理检查时的 Polymarket 地域限制检测结果
|
||||
*/
|
||||
data class ProxyCheckGeoblockResult(
|
||||
val checked: Boolean,
|
||||
val blocked: Boolean? = null,
|
||||
val ip: String? = null,
|
||||
val country: String? = null,
|
||||
val region: String? = null,
|
||||
val message: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 代理检查响应
|
||||
*/
|
||||
@@ -43,15 +55,22 @@ data class ProxyCheckResponse(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val responseTime: Long? = null, // 响应时间(毫秒)
|
||||
val latency: Long? = null // 延迟(毫秒),与 responseTime 相同,用于前端显示
|
||||
val latency: Long? = null, // 延迟(毫秒),与 responseTime 相同,用于前端显示
|
||||
val geoblock: ProxyCheckGeoblockResult? = null
|
||||
) {
|
||||
companion object {
|
||||
fun create(success: Boolean, message: String, responseTime: Long? = null): ProxyCheckResponse {
|
||||
fun create(
|
||||
success: Boolean,
|
||||
message: String,
|
||||
responseTime: Long? = null,
|
||||
geoblock: ProxyCheckGeoblockResult? = null
|
||||
): ProxyCheckResponse {
|
||||
return ProxyCheckResponse(
|
||||
success = success,
|
||||
message = message,
|
||||
responseTime = responseTime,
|
||||
latency = responseTime // latency 和 responseTime 相同
|
||||
latency = responseTime,
|
||||
geoblock = geoblock
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(可选,明文存储)
|
||||
|
||||
@@ -76,6 +76,12 @@ data class BacktestTask(
|
||||
@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, // 平均持仓时间(毫秒)
|
||||
|
||||
@@ -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"),
|
||||
@@ -250,7 +257,14 @@ enum class ErrorCode(
|
||||
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");
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+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
|
||||
}
|
||||
+608
-79
@@ -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 代币批准检查:pUSD 需授权的 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 "0xE111180000d2663C0091e4f400237545B87B996B", // 普通市场交易所
|
||||
"NEG_RISK_EXCHANGE" to "0xe2222d279d744050d28e00520010520000310F59", // 负风险市场交易所
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
@@ -537,7 +939,38 @@ class AccountService(
|
||||
throw RuntimeException("解密私钥失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包。
|
||||
* 由 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
|
||||
*/
|
||||
@@ -698,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 &&
|
||||
@@ -731,7 +1164,7 @@ class AccountService(
|
||||
onFailure = { e ->
|
||||
return Result.failure(Exception("查询仓位失败: ${e.message}"))
|
||||
}
|
||||
) ?: return Result.failure(IllegalArgumentException("仓位不存在"))
|
||||
)
|
||||
|
||||
// 4. 计算实际卖出数量
|
||||
val sellQuantity = if (percentDecimal != null) {
|
||||
@@ -811,23 +1244,10 @@ class AccountService(
|
||||
else -> "GTC"
|
||||
}
|
||||
|
||||
// GTC 和 FOK 订单的 expiration 必须为 "0"
|
||||
// 只有 GTD 订单才需要设置具体的过期时间
|
||||
val expiration = "0"
|
||||
|
||||
// 7. 解密私钥
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
|
||||
val feeRateResult = clobService.getFeeRate(tokenId)
|
||||
val feeRateBps = if (feeRateResult.isSuccess) {
|
||||
feeRateResult.getOrNull()?.toString() ?: "0"
|
||||
} else {
|
||||
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量)
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -836,10 +1256,7 @@ class AccountService(
|
||||
side = "SELL",
|
||||
price = sellPrice,
|
||||
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
|
||||
signatureType = 2, // Browser Wallet(与正确订单数据一致)
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = expiration
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建并签名订单失败", e)
|
||||
@@ -850,9 +1267,8 @@ class AccountService(
|
||||
|
||||
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey!!, // API Key
|
||||
orderType = orderType,
|
||||
deferExec = false
|
||||
owner = account.apiKey, // API Key
|
||||
orderType = orderType
|
||||
)
|
||||
|
||||
// 13. 解密 API 凭证并使用账户的API凭证创建订单
|
||||
@@ -870,7 +1286,7 @@ class AccountService(
|
||||
}
|
||||
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey!!,
|
||||
account.apiKey,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
account.walletAddress
|
||||
@@ -901,13 +1317,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,
|
||||
@@ -918,7 +1343,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)
|
||||
@@ -938,7 +1364,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)
|
||||
|
||||
@@ -983,6 +1409,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)
|
||||
|
||||
@@ -1001,8 +1435,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,
|
||||
@@ -1012,7 +1448,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
|
||||
@@ -1189,13 +1625,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("赎回仓位列表不能为空"))
|
||||
}
|
||||
@@ -1217,7 +1646,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>>()
|
||||
@@ -1260,7 +1699,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
|
||||
|
||||
@@ -1271,20 +1710,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(
|
||||
@@ -1292,11 +1742,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 ->
|
||||
@@ -1315,7 +1792,7 @@ class AccountService(
|
||||
)
|
||||
}
|
||||
|
||||
// 6. 发送赎回推送通知(异步,不阻塞)
|
||||
// 7. 发送赎回推送通知(异步,不阻塞)
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取当前语言设置
|
||||
@@ -1329,16 +1806,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) {
|
||||
@@ -1405,6 +1908,32 @@ class AccountService(
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将账户的 USDC.e wrap 为 pUSD
|
||||
*/
|
||||
suspend fun wrapUsdcToPusd(accountId: Long): Result<String?> {
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
if (account.proxyAddress.isBlank()) {
|
||||
return Result.failure(IllegalStateException("账户代理地址不存在"))
|
||||
}
|
||||
val privateKey = cryptoUtils.decrypt(account.privateKey)
|
||||
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
return blockchainService.wrapUsdcToPusd(privateKey, account.proxyAddress, walletType)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 USDC.e 余额(用于迁移提示)
|
||||
*/
|
||||
suspend fun getUsdceBalance(accountId: Long): Result<BigDecimal> {
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
if (account.proxyAddress.isBlank()) {
|
||||
return Result.failure(IllegalStateException("账户代理地址不存在"))
|
||||
}
|
||||
return blockchainService.queryUsdceBalance(account.proxyAddress)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+24
-10
@@ -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 ->
|
||||
@@ -455,9 +467,11 @@ class PositionCheckService(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理待赎回仓位异常: ${e.message}", e)
|
||||
} finally {
|
||||
redeemCheckInProgress.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 逻辑2:处理未卖出订单
|
||||
* 检查所有未卖出的订单,匹配仓位
|
||||
|
||||
+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 则执行解包。
|
||||
* 同一时间仅允许单次执行;若上次执行未结束则本次忽略(与现有轮询逻辑一致)。
|
||||
* 若未配置 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-4
@@ -73,6 +73,8 @@ class BacktestExecutionService(
|
||||
minOrderDepth = null, // 回测无实时订单簿数据
|
||||
maxSpread = null, // 回测无实时价差数据
|
||||
maxPositionValue = task.maxPositionValue,
|
||||
minPrice = task.minPrice, // 最低价格
|
||||
maxPrice = task.maxPrice, // 最高价格
|
||||
keywordFilterMode = task.keywordFilterMode,
|
||||
keywords = task.keywords,
|
||||
configName = null,
|
||||
@@ -406,10 +408,25 @@ class BacktestExecutionService(
|
||||
val cost = actualSellQuantity.multiply(position.avgPrice)
|
||||
val profitLoss = netAmount.subtract(cost)
|
||||
|
||||
// 更新余额和持仓
|
||||
// Bug #39 Fix: correctly reduce position quantity after sell
|
||||
currentBalance += netAmount
|
||||
if (position.quantity <= BigDecimal.ZERO) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// 记录交易到当前页列表
|
||||
@@ -630,7 +647,8 @@ class BacktestExecutionService(
|
||||
val settlementPrice = avgPrice
|
||||
|
||||
val settlementValue = quantity.multiply(settlementPrice)
|
||||
val profitLoss = settlementValue.negate()
|
||||
// Bug #39 Fix: profitLoss for closed settlement at avgPrice should be ~0
|
||||
val profitLoss = settlementValue.subtract(quantity.multiply(avgPrice))
|
||||
|
||||
balance += settlementValue
|
||||
|
||||
@@ -700,7 +718,8 @@ class BacktestExecutionService(
|
||||
if (balance > peakBalance) {
|
||||
peakBalance = balance
|
||||
}
|
||||
val drawdown = peakBalance - runningBalance
|
||||
// Bug #39 Fix: use current balance, not runningBalance from previous iteration
|
||||
val drawdown = peakBalance - balance
|
||||
if (drawdown > maxDrawdown) {
|
||||
maxDrawdown = drawdown
|
||||
}
|
||||
|
||||
@@ -82,7 +82,9 @@ class BacktestService(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal()
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
)
|
||||
|
||||
backtestTaskRepository.save(task)
|
||||
@@ -192,7 +194,9 @@ class BacktestService(
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
maxPositionValue = task.maxPositionValue?.toPlainString()
|
||||
maxPositionValue = task.maxPositionValue?.toPlainString(),
|
||||
minPrice = task.minPrice?.toPlainString(),
|
||||
maxPrice = task.maxPrice?.toPlainString()
|
||||
)
|
||||
|
||||
val statistics = BacktestStatisticsDto(
|
||||
@@ -379,7 +383,9 @@ class BacktestService(
|
||||
supportSell = source.supportSell,
|
||||
keywordFilterMode = source.keywordFilterMode,
|
||||
keywords = source.keywords,
|
||||
maxPositionValue = source.maxPositionValue
|
||||
maxPositionValue = source.maxPositionValue,
|
||||
minPrice = source.minPrice,
|
||||
maxPrice = source.maxPrice
|
||||
)
|
||||
|
||||
backtestTaskRepository.save(newTask)
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
+350
-21
@@ -10,6 +10,7 @@ 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
|
||||
@@ -17,6 +18,7 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
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
|
||||
@@ -37,8 +39,8 @@ class BlockchainService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
|
||||
|
||||
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon)
|
||||
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
// pUSD 合约地址(Polygon 主网,Polymarket 使用 Polygon)
|
||||
private val usdcContractAddress = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"
|
||||
|
||||
// Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户)
|
||||
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
|
||||
@@ -53,6 +55,9 @@ class BlockchainService(
|
||||
|
||||
// ConditionalTokens 合约地址(Polygon 主网)
|
||||
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||
|
||||
// Neg Risk WrappedCollateral 合约地址(Polygon)
|
||||
private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
|
||||
|
||||
// 空集合ID(用于计算collectionId)
|
||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
@@ -93,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) {
|
||||
@@ -110,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")
|
||||
@@ -242,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 余额
|
||||
@@ -580,45 +641,313 @@ 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 执行解包(解包后转入代理地址)
|
||||
* 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可执行解包后续资产处理。
|
||||
*
|
||||
* Safe 与 Magic 使用同一套逻辑:同一 [createUnwrapWcolTx] + [RelayClientService.execute];
|
||||
* Safe 走 execTransaction,Magic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount)。
|
||||
*
|
||||
* @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)
|
||||
}
|
||||
}
|
||||
|
||||
// USDC.e 合约地址(仅用于 wrap 查询)
|
||||
private val usdceContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
|
||||
/**
|
||||
* 查询 USDC.e 余额(用于 wrap 前检查)
|
||||
*/
|
||||
suspend fun queryUsdceBalance(walletAddress: String): Result<BigDecimal> {
|
||||
return try {
|
||||
val rpcApi = polygonRpcApi
|
||||
val functionSelector = "0x70a08231"
|
||||
val paddedAddress = walletAddress.removePrefix("0x").lowercase().padStart(64, '0')
|
||||
val data = functionSelector + paddedAddress
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(mapOf("to" to usdceContractAddress, "data" to data), "latest")
|
||||
)
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败"))
|
||||
}
|
||||
val hexBalance = response.body()!!.result?.asString ?: return Result.failure(Exception("result 为空"))
|
||||
val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16)
|
||||
Result.success(BigDecimal(balanceWei).divide(BigDecimal("1000000")))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 USDC.e wrap 为 pUSD
|
||||
* 步骤:1) 检查 USDC.e 余额 → 2) approve CollateralOnramp → 3) wrap
|
||||
*/
|
||||
suspend fun wrapUsdcToPusd(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
walletType: WalletType
|
||||
): Result<String?> {
|
||||
return try {
|
||||
val balanceResult = queryUsdceBalance(proxyAddress)
|
||||
val balance = balanceResult.getOrElse {
|
||||
logger.warn("查询 USDC.e 余额失败: ${it.message}")
|
||||
return Result.failure(it)
|
||||
}
|
||||
if (balance <= BigDecimal.ZERO) {
|
||||
return Result.success(null)
|
||||
}
|
||||
val wrapAmountWei = balance.movePointRight(6).toBigInteger()
|
||||
logger.info("开始 wrap USDC.e → pUSD: proxy=${proxyAddress.take(10)}..., amount=$balance")
|
||||
|
||||
val unlimitedAllowance = BigInteger.valueOf(2).pow(256).minus(BigInteger.ONE)
|
||||
val approveTx = relayClientService.createUsdceApproveForWrapTx(unlimitedAllowance)
|
||||
val wrapTx = relayClientService.createWrapToPusdTx(proxyAddress, wrapAmountWei)
|
||||
if (walletType == WalletType.MAGIC) {
|
||||
// MAGIC 账户走 PROXY 时,不使用 Safe MultiSend(delegatecall);
|
||||
// 改为顺序执行两笔 CALL,避免内层 delegatecall 回滚导致“外层成功但业务失败”。
|
||||
val approveResult = relayClientService.execute(privateKey, proxyAddress, approveTx, walletType)
|
||||
val approveHash = approveResult.getOrElse {
|
||||
logger.error("USDC.e approve 失败: ${it.message}", it)
|
||||
return Result.failure(it)
|
||||
}
|
||||
logger.info("USDC.e approve 成功: txHash=$approveHash")
|
||||
|
||||
val wrapResult = relayClientService.execute(privateKey, proxyAddress, wrapTx, walletType)
|
||||
wrapResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
logger.info("USDC.e → pUSD wrap 成功: txHash=$txHash")
|
||||
Result.success(txHash)
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
val safeTx = relayClientService.createMultiSendTx(listOf(approveTx, wrapTx))
|
||||
val executeResult = relayClientService.execute(privateKey, proxyAddress, safeTx, walletType)
|
||||
executeResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
logger.info("USDC.e → pUSD wrap 成功: txHash=$txHash")
|
||||
Result.success(txHash)
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("USDC.e → pUSD wrap 异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理钱包的 nonce(用于构建 Safe 交易)
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+20
-28
@@ -98,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
|
||||
*/
|
||||
@@ -107,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)
|
||||
|
||||
@@ -158,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)
|
||||
|
||||
|
||||
+2
-10
@@ -223,15 +223,7 @@ class PolymarketClobService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建订单(已废弃,使用 createSignedOrder 代替)
|
||||
* @deprecated 使用 createSignedOrder 代替,需要签名的订单对象
|
||||
*/
|
||||
@Deprecated("使用 createSignedOrder 代替")
|
||||
suspend fun createOrder(request: CreateOrderRequest): Result<OrderResponse> {
|
||||
return Result.failure(UnsupportedOperationException("已废弃,请使用 createSignedOrder 方法"))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 创建签名的订单
|
||||
* 注意:此方法需要完整的订单签名逻辑,当前为占位实现
|
||||
@@ -401,7 +393,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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+71
-6
@@ -45,7 +45,9 @@ object OnChainWsUtils {
|
||||
}
|
||||
|
||||
// 合约地址
|
||||
const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
const val PUSD_CONTRACT = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // V2 pUSD
|
||||
const val USDC_CONTRACT = PUSD_CONTRACT // 默认使用 pUSD
|
||||
private val COLLATERAL_CONTRACTS = setOf(PUSD_CONTRACT.lowercase())
|
||||
const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
|
||||
const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||
const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
|
||||
@@ -96,8 +98,8 @@ object OnChainWsUtils {
|
||||
val t0 = topics[0].lowercase()
|
||||
val data = log.get("data")?.asString ?: "0x"
|
||||
|
||||
// USDC ERC20 Transfer
|
||||
if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
|
||||
// 抵押品 ERC20 Transfer(当前仅匹配 pUSD)
|
||||
if (address in COLLATERAL_CONTRACTS && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
|
||||
val from = topicToAddress(topics[1])
|
||||
val to = topicToAddress(topics[2])
|
||||
val value = hexToBigInt(data)
|
||||
@@ -149,6 +151,42 @@ object OnChainWsUtils {
|
||||
return Pair(erc20, erc1155)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 CLOB 交易历史获取 Leader 真实成交价(用于无 USDC 转账的 ERC1155-only 场景)
|
||||
* 查询该 token 最近的成交记录,匹配 side 方向的第一条作为成交价
|
||||
* 返回 usdcRaw = price × sizeRaw
|
||||
*/
|
||||
private suspend fun fetchEstimatedUsdcRaw(
|
||||
tokenId: String,
|
||||
side: String,
|
||||
sizeRaw: BigInteger,
|
||||
retrofitFactory: RetrofitFactory
|
||||
): BigInteger? {
|
||||
return try {
|
||||
val clobApi = retrofitFactory.createClobApiWithoutAuth()
|
||||
val response = clobApi.getTrades(asset_id = tokenId)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
logger.warn("CLOB 交易历史查询失败: tokenId=$tokenId, code=${response.code()}")
|
||||
return null
|
||||
}
|
||||
val trades = response.body()!!.data
|
||||
val clobSide = side.lowercase()
|
||||
val matchedTrade = trades.firstOrNull { it.side.equals(clobSide, ignoreCase = true) }
|
||||
if (matchedTrade == null) {
|
||||
logger.warn("CLOB 交易历史中未找到匹配成交: tokenId=$tokenId, side=$clobSide, totalTrades=${trades.size}")
|
||||
return null
|
||||
}
|
||||
val price = matchedTrade.price.toBigDecimal()
|
||||
val usdcRaw = price.multiply(sizeRaw.toBigDecimal())
|
||||
.setScale(0, java.math.RoundingMode.DOWN).toBigInteger()
|
||||
logger.debug("CLOB 交易历史估算: tokenId=$tokenId, side=$clobSide, tradePrice=${matchedTrade.price}, tradeSize=${matchedTrade.size}, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw")
|
||||
usdcRaw
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 CLOB 交易历史失败: tokenId=$tokenId, side=$side, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Transfer 日志解析交易信息
|
||||
*/
|
||||
@@ -205,6 +243,32 @@ object OnChainWsUtils {
|
||||
asset = bestOutId
|
||||
sizeRaw = bestOutVal
|
||||
usdcRaw = usdcIn
|
||||
} else if (bestInId != null && bestInVal > BigInteger.ZERO && bestOutId == null
|
||||
&& usdcOut == BigInteger.ZERO && usdcIn == BigInteger.ZERO
|
||||
) {
|
||||
// BUY(无 USDC): 只收到 ERC1155 token,无 USDC 流动(CLOB 内部结算等场景)
|
||||
side = "BUY"
|
||||
asset = bestInId
|
||||
sizeRaw = bestInVal
|
||||
usdcRaw = fetchEstimatedUsdcRaw(bestInId.toString(), "BUY", bestInVal, retrofitFactory)
|
||||
?: run {
|
||||
logger.warn("无法获取估算价格(ERC1155-only BUY): txHash=$txHash, tokenId=$bestInId")
|
||||
return null
|
||||
}
|
||||
logger.debug("ERC1155-only BUY: txHash=$txHash, tokenId=$bestInId, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw")
|
||||
} else if (bestOutId != null && bestOutVal > BigInteger.ZERO && bestInId == null
|
||||
&& usdcOut == BigInteger.ZERO && usdcIn == BigInteger.ZERO
|
||||
) {
|
||||
// SELL(无 USDC): 只发出 ERC1155 token,无 USDC 流动
|
||||
side = "SELL"
|
||||
asset = bestOutId
|
||||
sizeRaw = bestOutVal
|
||||
usdcRaw = fetchEstimatedUsdcRaw(bestOutId.toString(), "SELL", bestOutVal, retrofitFactory)
|
||||
?: run {
|
||||
logger.warn("无法获取估算价格(ERC1155-only SELL): txHash=$txHash, tokenId=$bestOutId")
|
||||
return null
|
||||
}
|
||||
logger.debug("ERC1155-only SELL: txHash=$txHash, tokenId=$bestOutId, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw")
|
||||
} else {
|
||||
// 无法判断交易方向
|
||||
logger.debug("无法判断交易方向: txHash=$txHash, bestInId=$bestInId, bestInVal=$bestInVal, bestOutId=$bestOutId, bestOutVal=$bestOutVal, usdcOut=$usdcOut, usdcIn=$usdcIn")
|
||||
@@ -220,10 +284,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 +297,8 @@ object OnChainWsUtils {
|
||||
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
|
||||
user = walletAddress,
|
||||
outcomeIndex = marketInfo?.outcomeIndex,
|
||||
outcome = marketInfo?.outcome
|
||||
outcome = marketInfo?.outcome,
|
||||
tokenId = asset.toString()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -463,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,
|
||||
@@ -472,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)
|
||||
|
||||
+18
-4
@@ -20,7 +20,12 @@ 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
|
||||
|
||||
/**
|
||||
@@ -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 ->
|
||||
|
||||
+88
-83
@@ -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,30 @@ import java.math.RoundingMode
|
||||
*/
|
||||
@Service
|
||||
class OrderSigningService {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderSigningService::class.java)
|
||||
|
||||
// Polygon 主网合约地址
|
||||
private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
|
||||
|
||||
/**
|
||||
* 根据是否为 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
|
||||
}
|
||||
|
||||
// V2 合约地址
|
||||
private val EXCHANGE_CONTRACT = "0xE111180000d2663C0091e4f400237545B87B996B"
|
||||
private val NEG_RISK_EXCHANGE_CONTRACT = "0xe2222d279d744050d28e00520010520000310F59"
|
||||
private val CHAIN_ID = 137L
|
||||
|
||||
// USDC 有 6 位小数
|
||||
@@ -135,8 +155,8 @@ class OrderSigningService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并签名订单
|
||||
*
|
||||
* 创建并签名订单 (V2)
|
||||
*
|
||||
* @param privateKey 私钥(十六进制字符串)
|
||||
* @param makerAddress maker 地址(funder,通常是 proxyAddress)
|
||||
* @param tokenId token ID
|
||||
@@ -144,9 +164,7 @@ class OrderSigningService {
|
||||
* @param price 价格
|
||||
* @param size 数量
|
||||
* @param signatureType 签名类型(1: Email/Magic, 2: Browser Wallet, 0: EOA)
|
||||
* @param nonce nonce(默认 "0")
|
||||
* @param feeRateBps 费率基点(默认 "0")
|
||||
* @param expiration 过期时间戳(秒,0 表示永不过期)
|
||||
* @param exchangeContract 签约用 exchange 合约地址;null 时用标准 CTF Exchange,neg risk 市场需传 Neg Risk Exchange
|
||||
* @return 签名的订单对象
|
||||
*/
|
||||
fun createAndSignOrder(
|
||||
@@ -156,88 +174,83 @@ class OrderSigningService {
|
||||
side: String,
|
||||
price: String,
|
||||
size: String,
|
||||
signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致)
|
||||
nonce: String = "0",
|
||||
feeRateBps: String = "0",
|
||||
expiration: String = "0"
|
||||
signatureType: Int = 2,
|
||||
exchangeContract: String? = null
|
||||
): SignedOrderObject {
|
||||
try {
|
||||
// 1. 从私钥获取签名地址
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val credentials = Credentials.create(privateKeyBigInt.toString(16))
|
||||
// 统一转换为小写,确保与 EIP-712 编码时使用的地址格式一致
|
||||
// EIP-712 编码时地址会被转换为小写,所以订单对象中的地址也应该是小写
|
||||
val signerAddress = credentials.address.lowercase()
|
||||
|
||||
|
||||
// 2. 计算订单金额
|
||||
val amounts = calculateOrderAmounts(side, size, price)
|
||||
|
||||
// 3. 生成 salt(使用时间戳,毫秒)
|
||||
|
||||
// 3. 生成 salt 和 timestamp(V2: timestamp 替代 nonce 保证唯一性)
|
||||
val salt = generateSalt()
|
||||
|
||||
// 4. taker 地址(默认使用零地址)
|
||||
val taker = "0x0000000000000000000000000000000000000000"
|
||||
|
||||
val timestamp = System.currentTimeMillis().toString()
|
||||
|
||||
// 4. V2 字段默认值
|
||||
val metadata = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
val builder = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
// 5. 确保 maker 地址也是小写格式
|
||||
val makerAddressLower = makerAddress.lowercase()
|
||||
|
||||
// 打印签名前的订单参数(DEBUG 级别,避免敏感信息泄露)
|
||||
logger.debug("========== 订单签名前参数 ==========")
|
||||
|
||||
logger.debug("========== 订单签名前参数 (V2) ==========")
|
||||
logger.debug("订单方向: $side, 价格: $price, 数量: $size")
|
||||
logger.debug("Token ID: $tokenId")
|
||||
logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}")
|
||||
logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}")
|
||||
logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}")
|
||||
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
|
||||
logger.debug("Salt: $salt, Timestamp: $timestamp")
|
||||
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
|
||||
|
||||
|
||||
// 6. 构建订单数据并签名
|
||||
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,
|
||||
signer = signerAddress,
|
||||
taker = taker,
|
||||
tokenId = tokenId,
|
||||
makerAmount = amounts.makerAmount,
|
||||
takerAmount = amounts.takerAmount,
|
||||
expiration = expiration,
|
||||
nonce = nonce,
|
||||
feeRateBps = feeRateBps,
|
||||
side = side.uppercase(),
|
||||
signatureType = signatureType
|
||||
signatureType = signatureType,
|
||||
timestamp = timestamp,
|
||||
metadata = metadata,
|
||||
builder = builder
|
||||
)
|
||||
|
||||
// 7. 创建签名的订单对象
|
||||
// 注意:所有地址字段都使用小写格式,确保与签名时使用的地址一致
|
||||
|
||||
// 7. 创建 V2 签名订单对象
|
||||
return SignedOrderObject(
|
||||
salt = salt,
|
||||
maker = makerAddressLower,
|
||||
signer = signerAddress,
|
||||
taker = taker,
|
||||
taker = "0x0000000000000000000000000000000000000000",
|
||||
tokenId = tokenId,
|
||||
makerAmount = amounts.makerAmount,
|
||||
takerAmount = amounts.takerAmount,
|
||||
expiration = expiration,
|
||||
nonce = nonce,
|
||||
feeRateBps = feeRateBps,
|
||||
side = side.uppercase(),
|
||||
signatureType = signatureType,
|
||||
timestamp = timestamp,
|
||||
expiration = "0",
|
||||
metadata = metadata,
|
||||
builder = builder,
|
||||
signature = signature
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建并签名订单失败", e)
|
||||
throw RuntimeException("创建并签名订单失败: ${e.message}", e)
|
||||
logger.error("创建并签名订单失败 (V2)", e)
|
||||
throw RuntimeException("创建并签名订单失败 (V2): ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名订单(EIP-712)
|
||||
*
|
||||
* 参考: @polymarket/order-utils 的 ExchangeOrderBuilder
|
||||
* 签名订单 V2(EIP-712)
|
||||
*/
|
||||
private fun signOrder(
|
||||
privateKey: String,
|
||||
@@ -246,79 +259,71 @@ class OrderSigningService {
|
||||
salt: Long,
|
||||
maker: String,
|
||||
signer: String,
|
||||
taker: String,
|
||||
tokenId: String,
|
||||
makerAmount: String,
|
||||
takerAmount: String,
|
||||
expiration: String,
|
||||
nonce: String,
|
||||
feeRateBps: String,
|
||||
side: String,
|
||||
signatureType: Int
|
||||
signatureType: Int,
|
||||
timestamp: String,
|
||||
metadata: String,
|
||||
builder: String
|
||||
): String {
|
||||
try {
|
||||
// 1. 从私钥创建 BigInteger
|
||||
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
|
||||
|
||||
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 参数,而不是硬编码
|
||||
|
||||
val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder(
|
||||
salt = salt,
|
||||
maker = maker,
|
||||
signer = signer,
|
||||
taker = taker,
|
||||
tokenId = tokenId,
|
||||
makerAmount = makerAmount,
|
||||
takerAmount = takerAmount,
|
||||
expiration = expiration,
|
||||
nonce = nonce,
|
||||
feeRateBps = feeRateBps,
|
||||
side = side,
|
||||
signatureType = signatureType // 使用传入的参数
|
||||
signatureType = signatureType,
|
||||
timestamp = timestamp,
|
||||
metadata = metadata,
|
||||
builder = builder
|
||||
)
|
||||
|
||||
// 4. 计算完整的结构化数据哈希
|
||||
|
||||
val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
|
||||
domainSeparator = domainSeparator,
|
||||
messageHash = orderHash
|
||||
)
|
||||
|
||||
// 5. 使用私钥签名
|
||||
|
||||
val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false)
|
||||
|
||||
// 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)
|
||||
throw RuntimeException("订单签名失败: ${e.message}", e)
|
||||
logger.error("订单签名失败 (V2)", e)
|
||||
throw RuntimeException("订单签名失败 (V2): ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 并发安全:确保同一毫秒内多次调用生成唯一 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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+93
-68
@@ -258,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
|
||||
|
||||
// 先计算跟单金额(用于仓位检查)
|
||||
// 注意:这里先计算金额,即使后续被过滤也会记录
|
||||
@@ -293,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) {
|
||||
@@ -312,10 +329,10 @@ open class CopyOrderTrackingService(
|
||||
tokenId,
|
||||
tradePrice = tradePrice,
|
||||
copyOrderAmount = copyOrderAmount,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketTitle = marketTitle,
|
||||
marketEndDate = marketEndDate,
|
||||
outcomeIndex = trade.outcomeIndex
|
||||
outcomeIndex = effectiveOutcomeIndex
|
||||
)
|
||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||
if (!filterResult.isPassed) {
|
||||
@@ -325,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
|
||||
@@ -346,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(),
|
||||
@@ -376,7 +393,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
telegramNotificationService?.sendOrderFilteredNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcome = trade.outcome,
|
||||
@@ -545,16 +562,12 @@ open class CopyOrderTrackingService(
|
||||
// 解密私钥
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
|
||||
val feeRateResult = clobService.getFeeRate(tokenId)
|
||||
val feeRateBps = if (feeRateResult.isSuccess) {
|
||||
feeRateResult.getOrNull()?.toString() ?: "0"
|
||||
} else {
|
||||
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity")
|
||||
|
||||
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 毫秒
|
||||
@@ -563,6 +576,8 @@ open class CopyOrderTrackingService(
|
||||
clobApi = clobApi,
|
||||
privateKey = decryptedPrivateKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
walletAddress = account.walletAddress,
|
||||
exchangeContract = exchangeContract,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = buyPrice.toString(),
|
||||
@@ -570,7 +585,7 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
tradeId = trade.id,
|
||||
feeRateBps = feeRateBps
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
// 处理订单创建失败
|
||||
@@ -584,8 +599,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)
|
||||
@@ -597,7 +612,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
telegramNotificationService?.sendOrderFailureNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcome = null, // 失败时可能没有 outcome
|
||||
@@ -631,9 +646,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 买入数量(用于固定金额模式计算卖出比例)
|
||||
@@ -903,13 +918,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 需要使用实际卖出价格
|
||||
@@ -985,16 +1008,12 @@ open class CopyOrderTrackingService(
|
||||
// 8. 解密私钥(在方法开始时解密一次,后续复用)
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
|
||||
val feeRateResult = clobService.getFeeRate(tokenId)
|
||||
val feeRateBps = if (feeRateResult.isSuccess) {
|
||||
feeRateResult.getOrNull()?.toString() ?: "0"
|
||||
} else {
|
||||
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
// 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}")
|
||||
|
||||
// 9. 创建并签名卖出订单
|
||||
// 10. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -1003,27 +1022,24 @@ open class CopyOrderTrackingService(
|
||||
side = "SELL",
|
||||
price = sellPrice.toString(),
|
||||
size = totalMatched.toString(),
|
||||
signatureType = 2, // Browser Wallet
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||
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(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey,
|
||||
orderType = "FAK", // Fill-And-Kill
|
||||
deferExec = false
|
||||
orderType = "FAK" // Fill-And-Kill
|
||||
)
|
||||
|
||||
// 11. 创建带认证的CLOB API客户端(使用解密后的凭证)
|
||||
// 12. 创建带认证的CLOB API客户端(使用解密后的凭证)
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey,
|
||||
apiSecret,
|
||||
@@ -1031,12 +1047,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(),
|
||||
@@ -1044,7 +1061,7 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id,
|
||||
tradeId = leaderSellTrade.id,
|
||||
feeRateBps = feeRateBps
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
if (createOrderResult.isFailure) {
|
||||
@@ -1128,7 +1145,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 价格
|
||||
@@ -1136,13 +1155,15 @@ open class CopyOrderTrackingService(
|
||||
* @param owner API Key(用于owner字段)
|
||||
* @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,
|
||||
@@ -1150,7 +1171,7 @@ open class CopyOrderTrackingService(
|
||||
owner: String,
|
||||
copyTradingId: Long,
|
||||
tradeId: String,
|
||||
feeRateBps: String
|
||||
signatureType: Int
|
||||
): Result<String> {
|
||||
var lastError: Exception? = null
|
||||
|
||||
@@ -1165,20 +1186,24 @@ open class CopyOrderTrackingService(
|
||||
side = side,
|
||||
price = price,
|
||||
size = size,
|
||||
signatureType = 2, // Browser Wallet
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
signatureType = signatureType,
|
||||
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 的交易,避免订单长期挂单导致价格不匹配
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = owner,
|
||||
orderType = "FAK", // Fill-And-Kill
|
||||
deferExec = false
|
||||
orderType = "FAK" // Fill-And-Kill
|
||||
)
|
||||
|
||||
// 调用 API 创建订单
|
||||
|
||||
+60
-15
@@ -7,8 +7,10 @@ import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
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 kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
@@ -38,7 +40,8 @@ class OrderStatusUpdateService(
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val trackingService: CopyOrderTrackingService,
|
||||
private val marketService: MarketService, // 市场信息服务
|
||||
private val telegramNotificationService: TelegramNotificationService?
|
||||
private val telegramNotificationService: TelegramNotificationService?,
|
||||
private val blockchainService: com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java)
|
||||
@@ -198,8 +201,6 @@ class OrderStatusUpdateService(
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("检查 ${ordersToCheck.size} 个30秒前创建的订单是否成交")
|
||||
|
||||
// 按账户分组,避免重复创建 API 客户端
|
||||
val ordersByAccount = ordersToCheck.groupBy { it.accountId }
|
||||
|
||||
@@ -557,11 +558,13 @@ class OrderStatusUpdateService(
|
||||
|
||||
logger.info("更新卖出订单价格成功: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
|
||||
|
||||
// 发送通知(使用实际价格)
|
||||
// 发送通知(使用实际成交价)
|
||||
sendSellOrderNotification(
|
||||
record = updatedRecord,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
avgFilledPrice = actualSellPrice.toString(),
|
||||
filled = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
@@ -590,11 +593,13 @@ class OrderStatusUpdateService(
|
||||
|
||||
logger.debug("卖出订单价格无需更新: orderId=${record.sellOrderId}, price=$actualSellPrice")
|
||||
|
||||
// 发送通知
|
||||
// 发送通知(使用实际成交价)
|
||||
sendSellOrderNotification(
|
||||
record = updatedRecord,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
avgFilledPrice = actualSellPrice.toString(),
|
||||
filled = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
@@ -810,9 +815,11 @@ class OrderStatusUpdateService(
|
||||
val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price
|
||||
val actualSize = orderDetail.originalSize?.toSafeBigDecimal() ?: order.quantity
|
||||
val actualOutcome = orderDetail.outcome
|
||||
// 使用交易所订单的实际创建时间(API返回秒级,转为毫秒)
|
||||
val actualCreatedAt = if (orderDetail.createdAt > 0) orderDetail.createdAt * 1000 else order.createdAt
|
||||
|
||||
// 更新订单数据(如果实际数据与临时数据不同)
|
||||
val needUpdate = actualPrice != order.price || actualSize != order.quantity
|
||||
val needUpdate = actualPrice != order.price || actualSize != order.quantity || actualCreatedAt != order.createdAt
|
||||
|
||||
// 先保存更新后的订单,标记 notificationSent = true
|
||||
// 这样可以防止其他并发任务重复发送通知
|
||||
@@ -833,7 +840,7 @@ class OrderStatusUpdateService(
|
||||
status = order.status,
|
||||
notificationSent = true, // 标记为已发送通知
|
||||
source = order.source, // 保留原始订单来源
|
||||
createdAt = order.createdAt,
|
||||
createdAt = actualCreatedAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -846,12 +853,24 @@ class OrderStatusUpdateService(
|
||||
logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}")
|
||||
}
|
||||
|
||||
// 发送通知(使用实际数据)
|
||||
// 有成交时按公式计算实际成交价:original_size * price / size_matched,数量用 size_matched
|
||||
val sizeMatchedDec = orderDetail.sizeMatched.toSafeBigDecimal()
|
||||
val avgFilledPriceStr = if (sizeMatchedDec.gt(BigDecimal.ZERO)) {
|
||||
orderDetail.originalSize.toSafeBigDecimal()
|
||||
.multi(orderDetail.price)
|
||||
.div(sizeMatchedDec, 18)
|
||||
.toPlainString()
|
||||
} else null
|
||||
val filledSize = orderDetail.sizeMatched
|
||||
|
||||
// 发送通知(使用实际数据,优先展示平均成交价)
|
||||
sendBuyOrderNotification(
|
||||
order = updatedOrder,
|
||||
actualPrice = actualPrice.toString(),
|
||||
actualSize = actualSize.toString(),
|
||||
actualOutcome = actualOutcome,
|
||||
avgFilledPrice = avgFilledPriceStr,
|
||||
filled = filledSize,
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
@@ -878,6 +897,8 @@ class OrderStatusUpdateService(
|
||||
actualPrice: String? = null,
|
||||
actualSize: String? = null,
|
||||
actualOutcome: String? = null,
|
||||
avgFilledPrice: String? = null, // 平均成交价(有成交时用于 TG 展示)
|
||||
filled: String? = null, // 已成交数量(与 avgFilledPrice 一起用于金额计算)
|
||||
account: Account? = null,
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
@@ -932,14 +953,24 @@ class OrderStatusUpdateService(
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
// 查询可用余额
|
||||
val availableBalance = try {
|
||||
blockchainService.getUsdcBalance(finalAccount.walletAddress, finalAccount.proxyAddress).getOrNull()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询可用余额失败: accountId=${finalAccount.id}, ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知(优先使用平均成交价展示)
|
||||
telegramNotificationService.sendOrderSuccessNotification(
|
||||
orderId = order.buyOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = order.marketId,
|
||||
marketSlug = market?.eventSlug, // 跳转用的 slug
|
||||
side = "BUY",
|
||||
price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格
|
||||
price = actualPrice ?: order.price.toString(), // 限价,无 avgFilledPrice 时展示
|
||||
avgFilledPrice = avgFilledPrice,
|
||||
filled = filled,
|
||||
size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量
|
||||
outcome = actualOutcome, // 使用实际 outcome
|
||||
accountName = finalAccount.accountName,
|
||||
@@ -952,7 +983,8 @@ class OrderStatusUpdateService(
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName,
|
||||
orderTime = orderCreatedAt // 使用订单创建时间
|
||||
orderTime = orderCreatedAt, // 使用订单创建时间
|
||||
availableBalance = availableBalance
|
||||
)
|
||||
|
||||
logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}")
|
||||
@@ -971,6 +1003,8 @@ class OrderStatusUpdateService(
|
||||
actualPrice: String? = null,
|
||||
actualSize: String? = null,
|
||||
actualOutcome: String? = null,
|
||||
avgFilledPrice: String? = null, // 平均成交价(有成交时用于 TG 展示)
|
||||
filled: String? = null, // 已成交数量(与 avgFilledPrice 一起用于金额计算)
|
||||
account: Account? = null,
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
@@ -1025,14 +1059,24 @@ class OrderStatusUpdateService(
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
// 查询可用余额
|
||||
val availableBalance = try {
|
||||
blockchainService.getUsdcBalance(finalAccount.walletAddress, finalAccount.proxyAddress).getOrNull()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询可用余额失败: accountId=${finalAccount.id}, ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知(优先使用平均成交价展示)
|
||||
telegramNotificationService.sendOrderSuccessNotification(
|
||||
orderId = record.sellOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = record.marketId,
|
||||
marketSlug = market?.eventSlug, // 跳转用的 slug
|
||||
side = "SELL",
|
||||
price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格
|
||||
price = actualPrice ?: record.sellPrice.toString(), // 限价,无 avgFilledPrice 时展示
|
||||
avgFilledPrice = avgFilledPrice,
|
||||
filled = filled,
|
||||
size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量
|
||||
outcome = actualOutcome, // 使用实际 outcome
|
||||
accountName = finalAccount.accountName,
|
||||
@@ -1045,7 +1089,8 @@ class OrderStatusUpdateService(
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName,
|
||||
orderTime = orderCreatedAt // 使用订单创建时间
|
||||
orderTime = orderCreatedAt, // 使用订单创建时间
|
||||
availableBalance = availableBalance
|
||||
)
|
||||
|
||||
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}")
|
||||
|
||||
+843
@@ -0,0 +1,843 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
|
||||
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.toJson
|
||||
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.sync.Mutex
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.stereotype.Service
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 加密价差策略监控服务
|
||||
* 负责实时推送监控数据到前端
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailMonitorService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val binanceKlineService: BinanceKlineService,
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||
private val webSocketSubscriptionService: WebSocketSubscriptionService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailMonitorService::class.java)
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
/** 当前周期 token 映射 */
|
||||
private val currentPeriodTokenToStrategy = AtomicReference<Map<String, List<MonitorEntry>>>(emptyMap())
|
||||
|
||||
/** 下一周期 token 映射 */
|
||||
private val nextPeriodTokenToStrategy = AtomicReference<Map<String, List<MonitorEntry>>>(emptyMap())
|
||||
|
||||
/** strategyId -> 当前价格数据 */
|
||||
private val strategyPriceData = ConcurrentHashMap<Long, StrategyPriceData>()
|
||||
|
||||
/** strategyId -> 订阅者数量 */
|
||||
private val strategySubscribers = ConcurrentHashMap<Long, Int>()
|
||||
|
||||
private var currentPeriodWebSocket: WebSocket? = null
|
||||
private var nextPeriodWebSocket: WebSocket? = null
|
||||
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
|
||||
|
||||
private val client by lazy {
|
||||
createClient().build()
|
||||
}
|
||||
|
||||
private val reconnectDelayMs = 3_000L
|
||||
private var reconnectJob: Job? = null
|
||||
private val closedForNoSubscribers = AtomicBoolean(false)
|
||||
private val connectLock = Any()
|
||||
|
||||
/** 防止 refreshSubscription 并发执行(周期结束时定时器与消息可能同时触发) */
|
||||
private val refreshSubscriptionMutex = Mutex()
|
||||
|
||||
/** 周期结束倒计时 Job */
|
||||
private var periodEndCountdownJob: Job? = null
|
||||
|
||||
/** 定时推送 Job(每 1.5 秒推送一次,保证 BTC 价格和分时图持续更新) */
|
||||
private var periodicPushJob: Job? = null
|
||||
private val pushIntervalMs = 1_500L
|
||||
|
||||
/** 策略推送历史(用于中途进入时补全分时图,最多保留 300 条) */
|
||||
private val strategyPushHistory = ConcurrentHashMap<Long, MutableList<CryptoTailMonitorPushData>>()
|
||||
private val strategyHistoryPeriod = ConcurrentHashMap<Long, Long>()
|
||||
private val maxHistorySize = 300
|
||||
|
||||
/** price_change 推送节流:每策略最近一次推送时间,1s 内不重复推送 */
|
||||
private val lastPriceChangePushTime = ConcurrentHashMap<Long, Long>()
|
||||
private val priceChangePushThrottleMs = 1_000L
|
||||
|
||||
/** 当前周期/下一周期构建时缓存的市场标题,key = "strategyId-periodStartUnix",供推送携带 */
|
||||
private val marketTitleByStrategyPeriod = ConcurrentHashMap<String, String>()
|
||||
|
||||
data class MonitorEntry(
|
||||
val strategyId: Long,
|
||||
val strategy: CryptoTailStrategy,
|
||||
val periodStartUnix: Long,
|
||||
val outcomeIndex: Int,
|
||||
val tokenId: String,
|
||||
/** 是否为下一个周期(用于预先订阅) */
|
||||
val isNextPeriod: Boolean = false
|
||||
)
|
||||
|
||||
data class StrategyPriceData(
|
||||
val currentPriceUp: BigDecimal? = null,
|
||||
val currentPriceDown: BigDecimal? = null,
|
||||
/** BTC 开盘价 USDC(币安 K 线 open) */
|
||||
val openPriceBtc: BigDecimal? = null,
|
||||
val spreadUp: BigDecimal? = null,
|
||||
val spreadDown: BigDecimal? = null,
|
||||
val minSpreadLineUp: BigDecimal? = null,
|
||||
val minSpreadLineDown: BigDecimal? = null,
|
||||
val triggered: Boolean = false,
|
||||
val triggerDirection: String? = null,
|
||||
val lastUpdateTime: Long = System.currentTimeMillis(),
|
||||
/** 当前周期开始时间(用于双连接周期切换) */
|
||||
val periodStartUnix: Long? = null
|
||||
)
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
// 服务启动时不主动连接,等待前端订阅
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化监控数据
|
||||
*/
|
||||
fun initMonitor(request: CryptoTailMonitorInitRequest): Result<CryptoTailMonitorInitResponse> {
|
||||
return try {
|
||||
val strategy = strategyRepository.findById(request.strategyId).orElse(null)
|
||||
if (strategy == null) {
|
||||
return Result.failure(IllegalArgumentException("策略不存在"))
|
||||
}
|
||||
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null)
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val periodStartUnix = request.periodStartUnix
|
||||
?: ((nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds)
|
||||
|
||||
// 获取市场信息
|
||||
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
||||
val event = fetchEventBySlug(slug).getOrNull()
|
||||
val market = event?.markets?.firstOrNull()
|
||||
val tokenIds = parseClobTokenIds(market?.clobTokenIds)
|
||||
|
||||
// 获取开盘价(币安 K 线 open = BTC 价格 USDC)
|
||||
val openClose = binanceKlineService.getCurrentOpenClose(
|
||||
strategy.marketSlugPrefix,
|
||||
strategy.intervalSeconds,
|
||||
periodStartUnix
|
||||
)
|
||||
val openPriceBtc = openClose?.first
|
||||
|
||||
// 获取自动计算的最小价差
|
||||
var autoMinSpreadUp: BigDecimal? = null
|
||||
var autoMinSpreadDown: BigDecimal? = null
|
||||
if (strategy.spreadMode.name.uppercase() == "AUTO") {
|
||||
val autoSpreads = binanceKlineAutoSpreadService.computeAndCache(
|
||||
strategy.marketSlugPrefix,
|
||||
strategy.intervalSeconds,
|
||||
periodStartUnix
|
||||
)
|
||||
autoMinSpreadUp = autoSpreads?.first
|
||||
autoMinSpreadDown = autoSpreads?.second
|
||||
}
|
||||
|
||||
// 保存价格数据到缓存
|
||||
val priceData = StrategyPriceData(
|
||||
openPriceBtc = openPriceBtc,
|
||||
minSpreadLineUp = autoMinSpreadUp ?: strategy.spreadValue?.toSafeBigDecimal(),
|
||||
minSpreadLineDown = autoMinSpreadDown ?: strategy.spreadValue?.toSafeBigDecimal(),
|
||||
periodStartUnix = periodStartUnix
|
||||
)
|
||||
strategyPriceData[strategy.id!!] = priceData
|
||||
|
||||
val response = CryptoTailMonitorInitResponse(
|
||||
strategyId = strategy.id!!,
|
||||
name = strategy.name ?: "",
|
||||
accountId = strategy.accountId,
|
||||
accountName = account?.accountName ?: "",
|
||||
marketSlugPrefix = strategy.marketSlugPrefix,
|
||||
marketTitle = event?.title ?: strategy.marketSlugPrefix,
|
||||
intervalSeconds = strategy.intervalSeconds,
|
||||
periodStartUnix = periodStartUnix,
|
||||
windowStartSeconds = strategy.windowStartSeconds,
|
||||
windowEndSeconds = strategy.windowEndSeconds,
|
||||
minPrice = strategy.minPrice.toPlainString(),
|
||||
maxPrice = strategy.maxPrice.toPlainString(),
|
||||
minSpreadMode = strategy.spreadMode.name,
|
||||
spreadDirection = strategy.spreadDirection.name,
|
||||
minSpreadValue = strategy.spreadValue?.toPlainString(),
|
||||
autoMinSpreadUp = autoMinSpreadUp?.toPlainString(),
|
||||
autoMinSpreadDown = autoMinSpreadDown?.toPlainString(),
|
||||
openPriceBtc = openPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||
tokenIdUp = tokenIds.getOrNull(0),
|
||||
tokenIdDown = tokenIds.getOrNull(1),
|
||||
currentTimestamp = System.currentTimeMillis(),
|
||||
enabled = strategy.enabled,
|
||||
amountMode = strategy.amountMode,
|
||||
amountValue = strategy.amountValue.toPlainString()
|
||||
)
|
||||
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
logger.error("初始化监控失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅策略监控
|
||||
*/
|
||||
fun subscribe(sessionId: String, strategyId: Long, callback: (CryptoTailMonitorPushData) -> Unit) {
|
||||
// 增加订阅计数
|
||||
val count = strategySubscribers.merge(strategyId, 1) { old, inc -> old + inc } ?: 1
|
||||
|
||||
// 注册推送回调
|
||||
webSocketSubscriptionService.registerMonitorCallback(sessionId, strategyId, callback)
|
||||
|
||||
// 如果是第一个订阅者,启动 WebSocket 和定时推送
|
||||
if (count == 1) {
|
||||
scope.launch {
|
||||
refreshSubscription()
|
||||
}
|
||||
startPeriodicPush()
|
||||
}
|
||||
|
||||
// 立即发送当前数据
|
||||
scope.launch {
|
||||
try {
|
||||
sendCurrentData(sessionId, strategyId, callback)
|
||||
} catch (e: Exception) {
|
||||
logger.error("发送当前监控数据失败: $sessionId, ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅策略监控
|
||||
*/
|
||||
fun unsubscribe(sessionId: String, strategyId: Long) {
|
||||
// 减少订阅计数
|
||||
val currentCount = strategySubscribers[strategyId] ?: 0
|
||||
val newCount = (currentCount - 1).coerceAtLeast(0)
|
||||
|
||||
if (newCount == 0) {
|
||||
strategySubscribers.remove(strategyId)
|
||||
} else {
|
||||
strategySubscribers[strategyId] = newCount
|
||||
}
|
||||
|
||||
// 移除回调
|
||||
webSocketSubscriptionService.unregisterMonitorCallback(sessionId, strategyId)
|
||||
|
||||
// 如果没有订阅者,关闭 WebSocket 和定时推送
|
||||
if (newCount == 0) {
|
||||
scope.launch {
|
||||
refreshSubscription()
|
||||
}
|
||||
stopPeriodicPush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPeriodicPush() {
|
||||
if (periodicPushJob?.isActive == true) return
|
||||
periodicPushJob = scope.launch {
|
||||
while (strategySubscribers.isNotEmpty() && strategySubscribers.values.any { (it ?: 0) > 0 }) {
|
||||
delay(pushIntervalMs)
|
||||
if (closedForNoSubscribers.get()) continue
|
||||
val ids = strategySubscribers.filter { (it.value ?: 0) > 0 }.keys.toList()
|
||||
for (strategyId in ids) {
|
||||
try {
|
||||
val strategy = strategyRepository.findById(strategyId).orElse(null) ?: continue
|
||||
val priceData = strategyPriceData[strategyId] ?: continue
|
||||
val pushData = buildPushData(strategy, priceData)
|
||||
addToHistoryAndPush(strategyId, pushData)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("定时推送失败 strategyId=$strategyId: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPeriodicPush() {
|
||||
if (strategySubscribers.isEmpty() || strategySubscribers.values.all { (it ?: 0) <= 0 }) {
|
||||
periodicPushJob?.cancel()
|
||||
periodicPushJob = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送当前数据(含历史补全,用于中途进入时填充分时图)
|
||||
*/
|
||||
private suspend fun sendCurrentData(
|
||||
sessionId: String,
|
||||
strategyId: Long,
|
||||
callback: (CryptoTailMonitorPushData) -> Unit
|
||||
) {
|
||||
val strategy = strategyRepository.findById(strategyId).orElse(null) ?: return
|
||||
val priceData = strategyPriceData[strategyId] ?: StrategyPriceData()
|
||||
|
||||
val history = strategyPushHistory[strategyId]?.let { list ->
|
||||
synchronized(list) { list.toList() }
|
||||
} ?: emptyList()
|
||||
for (item in history) {
|
||||
callback(item)
|
||||
}
|
||||
|
||||
val pushData = buildPushData(strategy, priceData)
|
||||
callback(pushData)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新订阅:双连接模式。当前周期连接 + 下一周期连接;周期切换时关闭过期连接,下一连接晋升为当前,并新建下一周期连接。
|
||||
* 使用 Mutex 防止周期结束时 scheduleRefreshAtPeriodEnd 与 maybeRefreshSubscriptionIfPeriodChanged 同时触发导致重复执行。
|
||||
*/
|
||||
private suspend fun refreshSubscription() {
|
||||
if (!refreshSubscriptionMutex.tryLock()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
refreshSubscriptionInternal()
|
||||
} finally {
|
||||
refreshSubscriptionMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshSubscriptionInternal() {
|
||||
periodEndCountdownJob?.cancel()
|
||||
periodEndCountdownJob = null
|
||||
|
||||
val subscribedStrategyIds = strategySubscribers.keys.filter { (strategySubscribers[it] ?: 0) > 0 }
|
||||
if (subscribedStrategyIds.isEmpty()) {
|
||||
closeAllWebSockets()
|
||||
return
|
||||
}
|
||||
|
||||
val strategies = strategyRepository.findAllById(subscribedStrategyIds).filter { it.enabled && it.id != null }
|
||||
if (strategies.isEmpty()) {
|
||||
closeAllWebSockets()
|
||||
return
|
||||
}
|
||||
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val isSwitch = currentPeriodWebSocket != null
|
||||
|
||||
if (isSwitch) {
|
||||
// 周期切换:关闭当前周期连接,下一晋升为当前,新建下一周期连接
|
||||
closeCurrentPeriodWebSocket()
|
||||
currentPeriodWebSocket = nextPeriodWebSocket
|
||||
nextPeriodWebSocket = null
|
||||
val nextMap = nextPeriodTokenToStrategy.get()
|
||||
currentPeriodTokenToStrategy.set(nextMap)
|
||||
val nextPeriodByStrategy =
|
||||
nextMap.values.flatten().distinctBy { it.strategyId }.associate { it.strategyId to it.periodStartUnix }
|
||||
logger.info("周期切换:下一周期连接晋升为当前")
|
||||
for ((strategyId, periodStartUnix) in nextPeriodByStrategy) {
|
||||
updateStrategyPriceDataForPeriod(listOf(strategyId), periodStartUnix, pushDefault = true)
|
||||
}
|
||||
val (newNextTokenIds, newNextMap) = buildSubscriptionMapForNextPeriod(subscribedStrategyIds)
|
||||
nextPeriodTokenToStrategy.set(newNextMap)
|
||||
if (newNextTokenIds.isNotEmpty()) {
|
||||
connectNextPeriod(newNextTokenIds, newNextMap)
|
||||
} else {
|
||||
logger.info("下一周期市场尚未创建,仅建立空连接以便周期切换时复用")
|
||||
connectNextPeriod(emptyList(), emptyMap())
|
||||
}
|
||||
scheduleRefreshAtPeriodEnd(if (newNextMap.isNotEmpty()) newNextMap else nextMap)
|
||||
} else {
|
||||
// 首次:建立当前周期连接 + 下一周期连接
|
||||
val (currentTokenIds, currentMap) = buildSubscriptionMapForCurrentPeriod(subscribedStrategyIds)
|
||||
currentPeriodTokenToStrategy.set(currentMap)
|
||||
for (entry in currentMap.values.flatten().distinctBy { it.strategyId }) {
|
||||
updateStrategyPriceDataForPeriod(listOf(entry.strategyId), entry.periodStartUnix, pushDefault = false)
|
||||
}
|
||||
if (currentTokenIds.isEmpty()) {
|
||||
closeAllWebSockets()
|
||||
return
|
||||
}
|
||||
connectCurrentPeriod(currentTokenIds, currentMap)
|
||||
val (nextTokenIds, nextMap) = buildSubscriptionMapForNextPeriod(subscribedStrategyIds)
|
||||
nextPeriodTokenToStrategy.set(nextMap)
|
||||
if (nextTokenIds.isNotEmpty()) {
|
||||
connectNextPeriod(nextTokenIds, nextMap)
|
||||
} else {
|
||||
logger.info("下一周期市场尚未创建,先建立空连接,周期切换时会重新订阅")
|
||||
connectNextPeriod(emptyList(), emptyMap())
|
||||
}
|
||||
scheduleRefreshAtPeriodEnd(currentMap)
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建当前周期订阅(每个策略按自己的 interval 算当前周期) */
|
||||
private suspend fun buildSubscriptionMapForCurrentPeriod(strategyIds: List<Long>): Pair<List<String>, Map<String, List<MonitorEntry>>> {
|
||||
val strategies = strategyRepository.findAllById(strategyIds)
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val tokenIdSet = mutableSetOf<String>()
|
||||
val map = mutableMapOf<String, MutableList<MonitorEntry>>()
|
||||
|
||||
for (strategy in strategies) {
|
||||
if (!strategy.enabled || strategy.id == null) continue
|
||||
val strategyPeriod = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
|
||||
val slug = "${strategy.marketSlugPrefix}-$strategyPeriod"
|
||||
val event = fetchEventBySlug(slug).getOrNull() ?: continue
|
||||
marketTitleByStrategyPeriod["${strategy.id!!}-$strategyPeriod"] = event.title ?: strategy.marketSlugPrefix
|
||||
val market = event.markets?.firstOrNull() ?: continue
|
||||
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
||||
if (tokenIds.size < 2) continue
|
||||
for (i in tokenIds.indices) {
|
||||
tokenIdSet.add(tokenIds[i])
|
||||
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
|
||||
MonitorEntry(strategy.id!!, strategy, strategyPeriod, i, tokenIds[i], false)
|
||||
)
|
||||
}
|
||||
}
|
||||
return Pair(tokenIdSet.toList(), map)
|
||||
}
|
||||
|
||||
/** 构建下一周期订阅(每个策略按自己的 interval 算下一周期) */
|
||||
private suspend fun buildSubscriptionMapForNextPeriod(strategyIds: List<Long>): Pair<List<String>, Map<String, List<MonitorEntry>>> {
|
||||
val strategies = strategyRepository.findAllById(strategyIds)
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val tokenIdSet = mutableSetOf<String>()
|
||||
val map = mutableMapOf<String, MutableList<MonitorEntry>>()
|
||||
|
||||
for (strategy in strategies) {
|
||||
if (!strategy.enabled || strategy.id == null) {
|
||||
continue
|
||||
}
|
||||
val currentPeriod = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
|
||||
val nextPeriod = currentPeriod + strategy.intervalSeconds
|
||||
val slug = "${strategy.marketSlugPrefix}-$nextPeriod"
|
||||
val event = fetchEventBySlug(slug).getOrNull()
|
||||
if (event == null) {
|
||||
continue
|
||||
}
|
||||
marketTitleByStrategyPeriod["${strategy.id!!}-$nextPeriod"] = event.title ?: strategy.marketSlugPrefix
|
||||
val market = event.markets?.firstOrNull()
|
||||
if (market == null) {
|
||||
continue
|
||||
}
|
||||
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
||||
if (tokenIds.size < 2) {
|
||||
continue
|
||||
}
|
||||
for (i in tokenIds.indices) {
|
||||
tokenIdSet.add(tokenIds[i])
|
||||
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
|
||||
MonitorEntry(strategy.id!!, strategy, nextPeriod, i, tokenIds[i], true)
|
||||
)
|
||||
}
|
||||
}
|
||||
return Pair(tokenIdSet.toList(), map)
|
||||
}
|
||||
|
||||
/** 更新策略价格数据为指定周期(开盘价、价差线等),可选是否推送默认 0.5 */
|
||||
private suspend fun updateStrategyPriceDataForPeriod(
|
||||
strategyIds: List<Long>,
|
||||
periodStartUnix: Long,
|
||||
pushDefault: Boolean
|
||||
) {
|
||||
val strategies = strategyRepository.findAllById(strategyIds)
|
||||
for (strategy in strategies) {
|
||||
if (strategy.id == null) continue
|
||||
val openClose = binanceKlineService.getCurrentOpenClose(
|
||||
strategy.marketSlugPrefix,
|
||||
strategy.intervalSeconds,
|
||||
periodStartUnix
|
||||
)
|
||||
val openPriceBtc = openClose?.first
|
||||
var minSpreadLineUp: BigDecimal? = null
|
||||
var minSpreadLineDown: BigDecimal? = null
|
||||
when (strategy.spreadMode.name.uppercase()) {
|
||||
"FIXED" -> {
|
||||
minSpreadLineUp = strategy.spreadValue?.toSafeBigDecimal()
|
||||
minSpreadLineDown = strategy.spreadValue?.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
"AUTO" -> {
|
||||
val autoSpreads = binanceKlineAutoSpreadService.computeAndCache(
|
||||
strategy.marketSlugPrefix,
|
||||
strategy.intervalSeconds,
|
||||
periodStartUnix
|
||||
)
|
||||
minSpreadLineUp = autoSpreads?.first
|
||||
minSpreadLineDown = autoSpreads?.second
|
||||
}
|
||||
}
|
||||
val existingData = strategyPriceData[strategy.id] ?: StrategyPriceData()
|
||||
val periodChanged = existingData.periodStartUnix != null && existingData.periodStartUnix != periodStartUnix
|
||||
val newData = StrategyPriceData(
|
||||
currentPriceUp = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.currentPriceUp,
|
||||
currentPriceDown = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.currentPriceDown,
|
||||
spreadUp = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.spreadUp,
|
||||
spreadDown = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.spreadDown,
|
||||
openPriceBtc = openPriceBtc,
|
||||
minSpreadLineUp = minSpreadLineUp,
|
||||
minSpreadLineDown = minSpreadLineDown,
|
||||
periodStartUnix = periodStartUnix
|
||||
)
|
||||
strategyPriceData[strategy.id!!] = newData
|
||||
if (periodChanged && pushDefault) {
|
||||
val pushData = buildPushData(strategy, newData)
|
||||
addToHistoryAndPush(strategy.id!!, pushData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectCurrentPeriod(tokenIds: List<String>, map: Map<String, List<MonitorEntry>>) {
|
||||
if (currentPeriodWebSocket != null) return
|
||||
val request = Request.Builder().url(wsUrl).build()
|
||||
currentPeriodWebSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
closedForNoSubscribers.set(false)
|
||||
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
||||
try {
|
||||
webSocket.send(msg)
|
||||
logger.info("加密价差策略监控 WebSocket(当前周期)已连接并订阅: ${tokenIds.size} 个 token")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送当前周期订阅失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleMessage(webSocket, text)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (this@CryptoTailMonitorService.currentPeriodWebSocket == webSocket) {
|
||||
this@CryptoTailMonitorService.currentPeriodWebSocket = null
|
||||
if (!closedForNoSubscribers.get()) scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
if (this@CryptoTailMonitorService.currentPeriodWebSocket == webSocket) {
|
||||
this@CryptoTailMonitorService.currentPeriodWebSocket = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun connectNextPeriod(tokenIds: List<String>, map: Map<String, List<MonitorEntry>>) {
|
||||
if (nextPeriodWebSocket != null) {
|
||||
return
|
||||
}
|
||||
val request = Request.Builder().url(wsUrl).build()
|
||||
nextPeriodWebSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
||||
try {
|
||||
webSocket.send(msg)
|
||||
if (tokenIds.isEmpty()) {
|
||||
logger.info("加密价差策略监控 WebSocket(下一周期)已连接,暂无 token 订阅,等待周期切换后更新")
|
||||
} else {
|
||||
logger.info("加密价差策略监控 WebSocket(下一周期)已连接并订阅: ${tokenIds.size} 个 token")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送下一周期订阅失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleMessage(webSocket, text)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (this@CryptoTailMonitorService.nextPeriodWebSocket == webSocket) {
|
||||
this@CryptoTailMonitorService.nextPeriodWebSocket = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
if (this@CryptoTailMonitorService.nextPeriodWebSocket == webSocket) {
|
||||
this@CryptoTailMonitorService.nextPeriodWebSocket = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun closeCurrentPeriodWebSocket() {
|
||||
currentPeriodWebSocket?.close(1000, "period_ended")
|
||||
currentPeriodWebSocket = null
|
||||
logger.info("加密价差策略监控 WebSocket(当前周期)已关闭")
|
||||
}
|
||||
|
||||
private fun closeAllWebSockets() {
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
closedForNoSubscribers.set(true)
|
||||
currentPeriodWebSocket?.close(1000, "no_subscribers")
|
||||
currentPeriodWebSocket = null
|
||||
nextPeriodWebSocket?.close(1000, "no_subscribers")
|
||||
nextPeriodWebSocket = null
|
||||
logger.info("加密价差策略监控 WebSocket 已全部关闭(无订阅者)")
|
||||
}
|
||||
|
||||
private fun handleMessage(webSocket: WebSocket, text: String) {
|
||||
if (text == "pong" || text.isEmpty()) return
|
||||
if (closedForNoSubscribers.get()) return
|
||||
|
||||
maybeRefreshSubscriptionIfPeriodChanged()
|
||||
|
||||
val json = text.fromJson<com.google.gson.JsonObject>() ?: return
|
||||
val eventType = (json.get("event_type") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
||||
val map = currentPeriodTokenToStrategy.get()
|
||||
|
||||
when (eventType) {
|
||||
"price_change" -> {
|
||||
val priceChanges = json.get("price_changes") as? com.google.gson.JsonArray ?: return
|
||||
for (i in 0 until priceChanges.size()) {
|
||||
val pc = priceChanges.get(i) as? com.google.gson.JsonObject ?: continue
|
||||
val assetId = (pc.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: continue
|
||||
val bestBidStr = (pc.get("best_bid") as? com.google.gson.JsonPrimitive)?.asString
|
||||
val bestBid = bestBidStr?.toSafeBigDecimal()
|
||||
if (bestBid != null) onPriceUpdate(assetId, bestBid, map)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPriceUpdate(tokenId: String, bestBid: BigDecimal, map: Map<String, List<MonitorEntry>>) {
|
||||
if (closedForNoSubscribers.get()) return
|
||||
val entries = map[tokenId] ?: return
|
||||
|
||||
for (entry in entries) {
|
||||
val strategy = entry.strategy
|
||||
val priceData = strategyPriceData[strategy.id!!] ?: StrategyPriceData()
|
||||
|
||||
// 根据方向更新价格
|
||||
val newPriceData = if (entry.outcomeIndex == 0) {
|
||||
// Up 方向
|
||||
priceData.copy(
|
||||
currentPriceUp = bestBid,
|
||||
currentPriceDown = BigDecimal.ONE.subtract(bestBid),
|
||||
spreadUp = BigDecimal.ONE.subtract(bestBid),
|
||||
spreadDown = bestBid,
|
||||
lastUpdateTime = System.currentTimeMillis()
|
||||
)
|
||||
} else {
|
||||
// Down 方向
|
||||
priceData.copy(
|
||||
currentPriceDown = bestBid,
|
||||
currentPriceUp = BigDecimal.ONE.subtract(bestBid),
|
||||
spreadUp = bestBid,
|
||||
spreadDown = BigDecimal.ONE.subtract(bestBid),
|
||||
lastUpdateTime = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
strategyPriceData[strategy.id!!] = newPriceData
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val last = lastPriceChangePushTime[strategy.id!!] ?: 0L
|
||||
if (now - last >= priceChangePushThrottleMs) {
|
||||
lastPriceChangePushTime[strategy.id!!] = now
|
||||
val pushData = buildPushData(strategy, newPriceData)
|
||||
addToHistoryAndPush(strategy.id!!, pushData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addToHistoryAndPush(strategyId: Long, pushData: CryptoTailMonitorPushData) {
|
||||
addToHistory(strategyId, pushData)
|
||||
webSocketSubscriptionService.pushMonitorData(strategyId, pushData)
|
||||
}
|
||||
|
||||
private fun addToHistory(strategyId: Long, pushData: CryptoTailMonitorPushData) {
|
||||
val list = strategyPushHistory.getOrPut(strategyId) {
|
||||
Collections.synchronizedList(mutableListOf<CryptoTailMonitorPushData>())
|
||||
}
|
||||
synchronized(list) {
|
||||
val lastPeriod = strategyHistoryPeriod[strategyId]
|
||||
if (lastPeriod != null && lastPeriod != pushData.periodStartUnix) {
|
||||
list.clear()
|
||||
}
|
||||
strategyHistoryPeriod[strategyId] = pushData.periodStartUnix
|
||||
list.add(pushData)
|
||||
while (list.size > maxHistorySize) {
|
||||
list.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建推送数据
|
||||
* 最新价、价差使用币安 K 线的 BTC 价格(open/close)
|
||||
*/
|
||||
private fun buildPushData(strategy: CryptoTailStrategy, priceData: StrategyPriceData): CryptoTailMonitorPushData {
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val periodStartUnix = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
|
||||
val periodEndUnix = periodStartUnix + strategy.intervalSeconds
|
||||
val remainingSeconds = (periodEndUnix - nowSeconds).toInt().coerceAtLeast(0)
|
||||
|
||||
val windowStart = periodStartUnix + strategy.windowStartSeconds
|
||||
val windowEnd = periodStartUnix + strategy.windowEndSeconds
|
||||
val inTimeWindow = nowSeconds >= windowStart && nowSeconds < windowEnd
|
||||
|
||||
// 币安 K 线:open = 周期开盘价,close = 当前最新价(实时更新)
|
||||
val openClose = binanceKlineService.getCurrentOpenClose(
|
||||
strategy.marketSlugPrefix,
|
||||
strategy.intervalSeconds,
|
||||
periodStartUnix
|
||||
)
|
||||
val openPriceBtc = priceData.openPriceBtc ?: openClose?.first
|
||||
val currentPriceBtc = openClose?.second
|
||||
// K 线数据回来后更新缓存,供后续使用
|
||||
if (openPriceBtc != null && priceData.openPriceBtc == null && strategy.id != null) {
|
||||
strategyPriceData[strategy.id] = priceData.copy(openPriceBtc = openPriceBtc)
|
||||
}
|
||||
val spreadBtc = if (openPriceBtc != null && currentPriceBtc != null) {
|
||||
currentPriceBtc.subtract(openPriceBtc)
|
||||
} else null
|
||||
|
||||
// 判断价格区间(Polymarket 0-1)
|
||||
val currentUp = priceData.currentPriceUp
|
||||
val currentDown = priceData.currentPriceDown
|
||||
val inPriceRangeUp = currentUp != null &&
|
||||
currentUp >= strategy.minPrice && currentUp <= strategy.maxPrice
|
||||
val inPriceRangeDown = currentDown != null &&
|
||||
currentDown >= strategy.minPrice && currentDown <= strategy.maxPrice
|
||||
|
||||
val marketTitle = marketTitleByStrategyPeriod["${strategy.id!!}-$periodStartUnix"] ?: strategy.marketSlugPrefix
|
||||
|
||||
return CryptoTailMonitorPushData(
|
||||
strategyId = strategy.id!!,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
periodStartUnix = periodStartUnix,
|
||||
marketTitle = marketTitle,
|
||||
currentPriceUp = priceData.currentPriceUp?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
|
||||
currentPriceDown = priceData.currentPriceDown?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
|
||||
spreadUp = priceData.spreadUp?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
|
||||
spreadDown = priceData.spreadDown?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
|
||||
minSpreadLineUp = priceData.minSpreadLineUp?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||
minSpreadLineDown = priceData.minSpreadLineDown?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||
openPriceBtc = openPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||
currentPriceBtc = currentPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||
spreadBtc = spreadBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||
remainingSeconds = remainingSeconds,
|
||||
inTimeWindow = inTimeWindow,
|
||||
inPriceRangeUp = inPriceRangeUp,
|
||||
inPriceRangeDown = inPriceRangeDown,
|
||||
triggered = priceData.triggered,
|
||||
triggerDirection = priceData.triggerDirection,
|
||||
periodEnded = remainingSeconds <= 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun maybeRefreshSubscriptionIfPeriodChanged() {
|
||||
val subscribed = currentPeriodTokenToStrategy.get().values.flatten().distinctBy { it.strategyId }
|
||||
.associate { it.strategyId to it.periodStartUnix }
|
||||
if (subscribed.isEmpty()) return
|
||||
|
||||
val strategies = strategyRepository.findAllById(subscribed.keys)
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
|
||||
for (s in strategies) {
|
||||
if (s.id == null) continue
|
||||
val currentPeriod = (nowSeconds / s.intervalSeconds) * s.intervalSeconds
|
||||
val subPeriod = subscribed[s.id] ?: continue
|
||||
if (currentPeriod != subPeriod) {
|
||||
scope.launch { refreshSubscription() }
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleRefreshAtPeriodEnd(newMap: Map<String, List<MonitorEntry>>) {
|
||||
val entries = newMap.values.flatten()
|
||||
if (entries.isEmpty()) return
|
||||
|
||||
val nextPeriodEndSeconds = entries.minOf { it.periodStartUnix + it.strategy.intervalSeconds }
|
||||
val delayMs = (nextPeriodEndSeconds * 1000) - System.currentTimeMillis() + 2000
|
||||
if (delayMs <= 0) return
|
||||
|
||||
periodEndCountdownJob = scope.launch {
|
||||
delay(delayMs)
|
||||
periodEndCountdownJob = null
|
||||
refreshSubscription()
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeWebSocketForNoSubscribers() {
|
||||
closeAllWebSockets()
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectJob?.isActive == true) return
|
||||
reconnectJob = scope.launch {
|
||||
delay(reconnectDelayMs)
|
||||
reconnectJob = null
|
||||
if (strategySubscribers.isNotEmpty()) {
|
||||
logger.info("加密价差策略监控 WebSocket 尝试重连")
|
||||
refreshSubscription()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
return try {
|
||||
val api = retrofitFactory.createGammaApi()
|
||||
val response = runBlocking { api.getEventBySlug(slug) }
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
|
||||
if (clobTokenIds.isNullOrBlank()) return emptyList()
|
||||
return clobTokenIds.fromJson<List<String>>() ?: emptyList()
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
reconnectJob?.cancel()
|
||||
periodEndCountdownJob?.cancel()
|
||||
periodicPushJob?.cancel()
|
||||
currentPeriodWebSocket?.close(1000, "shutdown")
|
||||
currentPeriodWebSocket = null
|
||||
nextPeriodWebSocket?.close(1000, "shutdown")
|
||||
nextPeriodWebSocket = null
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
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 com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 加密价差策略订单 TG 通知轮询服务(与跟单一致)
|
||||
* 定时查询「下单成功且未发 TG」的触发记录,通过 CLOB getOrder 获取订单详情后发送 TG 并标记已发。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailOrderNotificationPollingService(
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val marketService: MarketService,
|
||||
private val telegramNotificationService: TelegramNotificationService
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderNotificationPollingService::class.java)
|
||||
private val scopeJob = SupervisorJob()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + scopeJob)
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
|
||||
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||
this.applicationContext = applicationContext
|
||||
}
|
||||
|
||||
private fun getSelf(): CryptoTailOrderNotificationPollingService {
|
||||
return applicationContext?.getBean(CryptoTailOrderNotificationPollingService::class.java)
|
||||
?: throw IllegalStateException("ApplicationContext not initialized")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var notificationJob: Job? = null
|
||||
|
||||
@Scheduled(fixedDelay = 5000)
|
||||
fun scheduledSendPendingNotifications() {
|
||||
if (notificationJob != null && notificationJob!!.isActive) {
|
||||
logger.debug("上一轮加密价差策略 TG 通知任务仍在执行,跳过本次")
|
||||
return
|
||||
}
|
||||
notificationJob = scope.launch {
|
||||
try {
|
||||
getSelf().sendPendingNotifications()
|
||||
} catch (e: Exception) {
|
||||
logger.error("加密价差策略 TG 通知轮询异常: ${e.message}", e)
|
||||
} finally {
|
||||
notificationJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
suspend fun sendPendingNotifications() {
|
||||
val pending = triggerRepository.findByStatusAndOrderIdIsNotNullAndNotificationSentFalseOrderByCreatedAtAsc("success")
|
||||
if (pending.isEmpty()) return
|
||||
for (trigger in pending) {
|
||||
try {
|
||||
if (trigger.resolved) {
|
||||
trigger.notificationSent = true
|
||||
triggerRepository.save(trigger)
|
||||
logger.debug("触发已结算,跳过请求并标记已通知: triggerId=${trigger.id}, orderId=${trigger.orderId}")
|
||||
continue
|
||||
}
|
||||
if (sendNotificationForTrigger(trigger)) {
|
||||
trigger.notificationSent = true
|
||||
triggerRepository.save(trigger)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("加密价差策略 TG 通知单条失败: triggerId=${trigger.id}, orderId=${trigger.orderId}, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendNotificationForTrigger(trigger: CryptoTailStrategyTrigger): Boolean {
|
||||
val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return false
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: return false
|
||||
val orderId = trigger.orderId ?: return false
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.debug("账户未配置 API 凭证,跳过 TG: accountId=${account.id}")
|
||||
return false
|
||||
}
|
||||
val apiSecret = try {
|
||||
cryptoUtils.decrypt(account.apiSecret)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Secret 失败: accountId=${account.id}", e)
|
||||
return false
|
||||
}
|
||||
val apiPassphrase = try {
|
||||
cryptoUtils.decrypt(account.apiPassphrase)
|
||||
} catch (e: Exception) { "" }
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
account.walletAddress
|
||||
)
|
||||
val orderResponse = clobApi.getOrder(orderId)
|
||||
if (!orderResponse.isSuccessful) {
|
||||
logger.debug("查询订单详情失败,等待下次轮询: orderId=$orderId, code=${orderResponse.code()}")
|
||||
return false
|
||||
}
|
||||
val order = orderResponse.body() ?: run {
|
||||
logger.debug("订单详情为空,等待下次轮询: orderId=$orderId")
|
||||
return false
|
||||
}
|
||||
val market = marketService.getMarket(order.market)
|
||||
val marketTitle = trigger.marketTitle?.takeIf { it.isNotBlank() } ?: market?.title ?: order.market
|
||||
val orderTimeMs = if (order.createdAt < 1_000_000_000_000L) order.createdAt * 1000 else order.createdAt
|
||||
// 实际成交价 = original_size * price / size_matched,数量用 size_matched
|
||||
val sizeMatchedDec = order.sizeMatched.toSafeBigDecimal()
|
||||
val avgFilledPriceStr = if (sizeMatchedDec.gt(BigDecimal.ZERO)) {
|
||||
order.originalSize.toSafeBigDecimal()
|
||||
.multi(order.price)
|
||||
.div(sizeMatchedDec, 18)
|
||||
.toPlainString()
|
||||
} else null
|
||||
val filledSize = order.sizeMatched
|
||||
telegramNotificationService.sendCryptoTailOrderSuccessNotification(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = order.market,
|
||||
marketSlug = market?.eventSlug ?: market?.slug,
|
||||
side = order.side,
|
||||
outcome = order.outcome,
|
||||
price = order.price,
|
||||
size = order.originalSize,
|
||||
avgFilledPrice = avgFilledPriceStr,
|
||||
filled = filledSize,
|
||||
strategyName = strategy.name,
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
orderTime = orderTimeMs
|
||||
)
|
||||
logger.info("加密价差策略订单 TG 通知已发送: orderId=$orderId, strategyId=${strategy.id}, triggerId=${trigger.id}")
|
||||
return true
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
notificationJob?.cancel()
|
||||
notificationJob = null
|
||||
scopeJob.cancel()
|
||||
}
|
||||
}
|
||||
+460
@@ -0,0 +1,460 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.toJson
|
||||
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 kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.stereotype.Service
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 加密价差策略订单簿 WebSocket 监听:订阅 CLOB Market 频道,收到订单簿/价格变更时若满足条件立即触发下单。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailOrderbookWsService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val executionService: CryptoTailStrategyExecutionService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||
private val binanceKlineService: BinanceKlineService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderbookWsService::class.java)
|
||||
|
||||
private val scopeJob = SupervisorJob()
|
||||
private val scope = CoroutineScope(Dispatchers.Default + scopeJob)
|
||||
|
||||
/** tokenId -> list of (strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex) */
|
||||
private val tokenToEntries = AtomicReference<Map<String, List<WsBookEntry>>>(emptyMap())
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
|
||||
private val client = createClient().build()
|
||||
|
||||
/** 订阅成功后设置的倒计时 Job,在周期结束时自动刷新订阅 */
|
||||
private var periodEndCountdownJob: Job? = null
|
||||
|
||||
/** 重连延迟(毫秒) */
|
||||
private val reconnectDelayMs = 3_000L
|
||||
|
||||
/** 因无启用策略而主动关闭 WS 时置为 true,onClosing 中不触发重连 */
|
||||
private val closedForNoStrategies = AtomicBoolean(false)
|
||||
|
||||
/** 保护 connect() 的互斥锁,避免多线程并发创建连接 */
|
||||
private val connectLock = Any()
|
||||
|
||||
/** 保护 refreshAndSubscribe() 的互斥锁,避免多线程并发刷新订阅 */
|
||||
private val refreshLock = Any()
|
||||
|
||||
/** 标记是否正在刷新订阅,避免重复调用 */
|
||||
private val isRefreshing = AtomicBoolean(false)
|
||||
|
||||
data class WsBookEntry(
|
||||
val strategy: CryptoTailStrategy,
|
||||
val periodStartUnix: Long,
|
||||
val marketTitle: String?,
|
||||
val tokenIds: List<String>,
|
||||
val outcomeIndex: Int
|
||||
)
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
if (strategyRepository.findAllByEnabledTrue().isNotEmpty()) connect()
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
periodEndCountdownJob?.cancel()
|
||||
periodEndCountdownJob = null
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
synchronized(precomputeJobs) {
|
||||
precomputeJobs.forEach { it.cancel() }
|
||||
precomputeJobs.clear()
|
||||
}
|
||||
closedForNoStrategies.set(true)
|
||||
try {
|
||||
webSocket?.close(1000, "shutdown")
|
||||
} catch (e: Exception) {
|
||||
logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}")
|
||||
}
|
||||
webSocket = null
|
||||
scopeJob.cancel()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
synchronized(connectLock) {
|
||||
if (webSocket != null) return
|
||||
try {
|
||||
val request = Request.Builder().url(wsUrl).build()
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
logger.info("加密价差策略订单簿 WebSocket 已连接")
|
||||
refreshAndSubscribe(fromConnect = true)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleMessage(text)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
this@CryptoTailOrderbookWsService.webSocket = null
|
||||
if (!closedForNoStrategies.getAndSet(false)) scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
logger.warn("加密价差策略订单簿 WebSocket 异常: ${t.message}")
|
||||
this@CryptoTailOrderbookWsService.webSocket = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
logger.error("加密价差策略订单簿 WebSocket 连接失败: ${e.message}", e)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectJob?.isActive == true) return
|
||||
reconnectJob = scope.launch {
|
||||
delay(reconnectDelayMs)
|
||||
reconnectJob = null
|
||||
if (strategyRepository.findAllByEnabledTrue().isEmpty()) return@launch
|
||||
logger.info("加密价差策略订单簿 WebSocket 尝试重连")
|
||||
connect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(text: String) {
|
||||
if (text == "pong" || text.isEmpty()) return
|
||||
if (closedForNoStrategies.get()) return
|
||||
maybeRefreshSubscriptionIfPeriodChanged()
|
||||
val json = text.fromJson<com.google.gson.JsonObject>() ?: return
|
||||
val eventType = (json.get("event_type") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
||||
|
||||
when (eventType) {
|
||||
"book" -> {
|
||||
val assetId = (json.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
||||
val bids = json.get("bids") as? com.google.gson.JsonArray
|
||||
if (bids == null || bids.isEmpty) return
|
||||
// Polymarket book 的 bids 为价格升序,bids[0] 为最低买价;bestBid 应取最高买价
|
||||
var bestBid: BigDecimal? = null
|
||||
for (i in 0 until bids.size()) {
|
||||
val level = bids.get(i) as? com.google.gson.JsonObject ?: continue
|
||||
val p = (level.get("price") as? com.google.gson.JsonPrimitive)?.asString?.toSafeBigDecimal() ?: continue
|
||||
if (bestBid == null || p.gt(bestBid)) bestBid = p
|
||||
}
|
||||
if (bestBid != null) onBestBid(assetId, bestBid)
|
||||
}
|
||||
|
||||
"price_change" -> {
|
||||
val priceChanges = json.get("price_changes") as? com.google.gson.JsonArray ?: return
|
||||
for (i in 0 until priceChanges.size()) {
|
||||
val pc = priceChanges.get(i) as? com.google.gson.JsonObject ?: continue
|
||||
val assetId = (pc.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: continue
|
||||
val bestBidStr = (pc.get("best_bid") as? com.google.gson.JsonPrimitive)?.asString
|
||||
val bestBid = bestBidStr?.toSafeBigDecimal()
|
||||
if (bestBid != null) onBestBid(assetId, bestBid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBestBid(tokenId: String, bestBid: BigDecimal) {
|
||||
if (closedForNoStrategies.get()) return
|
||||
val entries = tokenToEntries.get()[tokenId]
|
||||
if (entries == null) return
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
for (e in entries) {
|
||||
val windowStart = e.periodStartUnix + e.strategy.windowStartSeconds
|
||||
val windowEnd = e.periodStartUnix + e.strategy.windowEndSeconds
|
||||
if (nowSeconds < windowStart || nowSeconds >= windowEnd) continue
|
||||
scope.launch {
|
||||
try {
|
||||
executionService.tryTriggerWithPriceFromWs(
|
||||
strategy = e.strategy,
|
||||
periodStartUnix = e.periodStartUnix,
|
||||
marketTitle = e.marketTitle,
|
||||
tokenIds = e.tokenIds,
|
||||
outcomeIndex = e.outcomeIndex,
|
||||
bestBid = bestBid
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
logger.error("WS 触发下单异常: strategyId=${e.strategy.id}, ${ex.message}", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件驱动:仅在收到 WS 消息时检查当前周期是否变化,若变化则刷新订阅,无需定时轮询。
|
||||
*/
|
||||
private fun maybeRefreshSubscriptionIfPeriodChanged() {
|
||||
val subscribed = tokenToEntries.get().values.flatten().distinctBy { it.strategy.id }
|
||||
.associate { it.strategy.id!! to it.periodStartUnix }
|
||||
if (subscribed.isEmpty()) return
|
||||
val strategies = strategyRepository.findAllByEnabledTrue()
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val currentStrategyIds = strategies.map { it.id!! }.toSet()
|
||||
if (subscribed.keys != currentStrategyIds) {
|
||||
refreshAndSubscribe()
|
||||
return
|
||||
}
|
||||
for (s in strategies) {
|
||||
val currentPeriod = (nowSeconds / s.intervalSeconds) * s.intervalSeconds
|
||||
val subPeriod = subscribed[s.id!!] ?: continue
|
||||
if (currentPeriod != subPeriod) {
|
||||
refreshAndSubscribe()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAndSubscribe(fromConnect: Boolean = false) {
|
||||
synchronized(refreshLock) {
|
||||
// 如果正在刷新,直接返回,避免重复调用
|
||||
if (isRefreshing.get()) {
|
||||
logger.debug("加密价差策略订阅刷新已在进行中,跳过本次调用")
|
||||
return
|
||||
}
|
||||
isRefreshing.set(true)
|
||||
}
|
||||
try {
|
||||
val strategies = strategyRepository.findAllByEnabledTrue()
|
||||
binanceKlineService.updateSubscriptions(strategies.map { it.marketSlugPrefix }.toSet())
|
||||
periodEndCountdownJob?.cancel()
|
||||
periodEndCountdownJob = null
|
||||
val oldTokenIds = tokenToEntries.get().keys.toSet()
|
||||
val (tokenIds, newMap) = buildSubscriptionMap()
|
||||
tokenToEntries.set(newMap)
|
||||
if (tokenIds.isEmpty()) {
|
||||
closeWebSocketForNoStrategies()
|
||||
return
|
||||
}
|
||||
if (!fromConnect) {
|
||||
if (webSocket == null) {
|
||||
connect()
|
||||
return
|
||||
}
|
||||
if (oldTokenIds == tokenIds.toSet()) {
|
||||
scheduleRefreshAtPeriodEnd(newMap)
|
||||
precomputeAutoSpreadForCurrentPeriods(newMap)
|
||||
return
|
||||
}
|
||||
closeWebSocketAndReconnect()
|
||||
return
|
||||
}
|
||||
val marketSlugs = newMap.values.asSequence().flatten()
|
||||
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
||||
.map { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
||||
.toList()
|
||||
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
||||
try {
|
||||
webSocket?.send(msg)
|
||||
logger.info("加密价差策略订单簿订阅: ${tokenIds.size} 个 token, 市场: $marketSlugs")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送订阅失败: ${e.message}")
|
||||
return
|
||||
}
|
||||
scheduleRefreshAtPeriodEnd(newMap)
|
||||
precomputeAutoSpreadForCurrentPeriods(newMap)
|
||||
} finally {
|
||||
isRefreshing.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅更新时关闭当前 WebSocket,由 onClosing 触发重连,重连后 onOpen 会重新订阅。
|
||||
*/
|
||||
private fun closeWebSocketAndReconnect() {
|
||||
val ws = webSocket
|
||||
if (ws != null) {
|
||||
webSocket = null
|
||||
try {
|
||||
ws.close(1000, "subscription_change")
|
||||
} catch (e: Exception) {
|
||||
logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}")
|
||||
}
|
||||
logger.info("加密价差策略订单簿 WebSocket 已关闭(订阅更新,将重连)")
|
||||
}
|
||||
}
|
||||
|
||||
/** 跟踪预计算价差的协程 Job,用于在关闭时取消 */
|
||||
private val precomputeJobs = mutableSetOf<Job>()
|
||||
|
||||
/**
|
||||
* AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期价差,触发时直接用缓存。
|
||||
*/
|
||||
private fun precomputeAutoSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
|
||||
val autoPeriods = newMap.values.asSequence().flatten()
|
||||
.filter { it.strategy.spreadMode == SpreadMode.AUTO }
|
||||
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.strategy.intervalSeconds}-${it.periodStartUnix}" }
|
||||
.map { Triple(it.strategy.marketSlugPrefix, it.strategy.intervalSeconds, it.periodStartUnix) }
|
||||
.toList()
|
||||
if (autoPeriods.isEmpty()) return
|
||||
val job = scope.launch {
|
||||
for ((marketPrefix, intervalSeconds, periodStartUnix) in autoPeriods) {
|
||||
try {
|
||||
val pair = binanceKlineAutoSpreadService.computeAndCache(marketPrefix, intervalSeconds, periodStartUnix)
|
||||
if (pair != null) {
|
||||
logger.info(
|
||||
"周期开始初始价差: market=$marketPrefix interval=${intervalSeconds}s periodStartUnix=$periodStartUnix " +
|
||||
"baseSpreadUp=${pair.first.toPlainString()} baseSpreadDown=${pair.second.toPlainString()}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("周期开始预计算 AUTO 价差失败: market=$marketPrefix interval=$intervalSeconds periodStartUnix=$periodStartUnix ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
synchronized(precomputeJobs) {
|
||||
precomputeJobs.add(job)
|
||||
// 清理已完成的 Job,避免集合无限增长
|
||||
precomputeJobs.removeIf { !it.isActive }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无启用策略或无需订阅时关闭 WebSocket,并取消重连;停用策略后刷新订阅会走到此处。
|
||||
*/
|
||||
private fun closeWebSocketForNoStrategies() {
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
val ws = webSocket
|
||||
if (ws != null) {
|
||||
closedForNoStrategies.set(true)
|
||||
webSocket = null
|
||||
try {
|
||||
ws.close(1000, "no_enabled_strategies")
|
||||
} catch (e: Exception) {
|
||||
logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}")
|
||||
}
|
||||
logger.info("加密价差策略订单簿 WebSocket 已关闭(无启用策略)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅成功后设置倒计时:在当前周期结束时自动刷新订阅,无需等消息触发。
|
||||
*/
|
||||
private fun scheduleRefreshAtPeriodEnd(newMap: Map<String, List<WsBookEntry>>) {
|
||||
val entries = newMap.values.flatten()
|
||||
if (entries.isEmpty()) return
|
||||
val nextPeriodEndSeconds = entries.minOf { it.periodStartUnix + it.strategy.intervalSeconds }
|
||||
val delayMs = (nextPeriodEndSeconds * 1000) - System.currentTimeMillis() + 2000
|
||||
if (delayMs <= 0) return
|
||||
periodEndCountdownJob = scope.launch {
|
||||
delay(delayMs)
|
||||
periodEndCountdownJob = null
|
||||
refreshAndSubscribe()
|
||||
}
|
||||
logger.debug("加密价差策略订单簿订阅倒计时: ${delayMs / 1000}s 后刷新")
|
||||
}
|
||||
|
||||
private fun buildSubscriptionMap(): Pair<List<String>, Map<String, List<WsBookEntry>>> {
|
||||
val strategies = strategyRepository.findAllByEnabledTrue()
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val tokenIdSet = mutableSetOf<String>()
|
||||
val map = mutableMapOf<String, MutableList<WsBookEntry>>()
|
||||
|
||||
for (strategy in strategies) {
|
||||
val interval = strategy.intervalSeconds
|
||||
val periodStartUnix = (nowSeconds / interval) * interval
|
||||
val windowEnd = periodStartUnix + strategy.windowEndSeconds
|
||||
if (nowSeconds >= windowEnd) {
|
||||
logger.debug("加密价差策略跳过(已过时间窗口): strategyId=${strategy.id}, slug=${strategy.marketSlugPrefix}, windowEnd=$windowEnd")
|
||||
continue
|
||||
}
|
||||
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
||||
val event = runBlocking { fetchEventBySlugWithRetry(slug).getOrNull() }
|
||||
if (event == null) {
|
||||
logger.warn("加密价差策略跳过(拉取事件失败): strategyId=${strategy.id}, slug=$slug,请确认 Gamma 是否存在该 slug 或稍后重试")
|
||||
continue
|
||||
}
|
||||
val market = event.markets?.firstOrNull()
|
||||
if (market == null) {
|
||||
logger.warn("加密价差策略跳过(事件无市场): strategyId=${strategy.id}, slug=$slug")
|
||||
continue
|
||||
}
|
||||
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
||||
if (tokenIds.size < 2) {
|
||||
logger.warn("加密价差策略跳过(token 数量不足): strategyId=${strategy.id}, slug=$slug, tokenCount=${tokenIds.size}")
|
||||
continue
|
||||
}
|
||||
tokenIdSet.addAll(tokenIds)
|
||||
for (i in tokenIds.indices) {
|
||||
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
|
||||
WsBookEntry(strategy, periodStartUnix, event.title, tokenIds, i)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Pair(tokenIdSet.toList(), map)
|
||||
}
|
||||
|
||||
/** 拉取事件,失败时重试最多 2 次(间隔 1s),避免瞬时失败导致多策略只订阅到其中一个 */
|
||||
private suspend fun fetchEventBySlugWithRetry(slug: String, maxAttempts: Int = 3): Result<GammaEventBySlugResponse> {
|
||||
var lastFailure: Exception? = null
|
||||
repeat(maxAttempts) { attempt ->
|
||||
val result = fetchEventBySlug(slug)
|
||||
if (result.isSuccess) return result
|
||||
lastFailure = result.exceptionOrNull() as? Exception
|
||||
if (attempt < maxAttempts - 1) delay(1000L)
|
||||
}
|
||||
return Result.failure(lastFailure ?: Exception("fetchEventBySlug failed"))
|
||||
}
|
||||
|
||||
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
return try {
|
||||
val api = retrofitFactory.createGammaApi()
|
||||
val response = api.getEventBySlug(slug)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
|
||||
if (clobTokenIds.isNullOrBlank()) return emptyList()
|
||||
val parsed = clobTokenIds.fromJson<List<String>>()
|
||||
return parsed ?: emptyList()
|
||||
}
|
||||
|
||||
@EventListener
|
||||
fun onStrategyChanged(event: CryptoTailStrategyChangedEvent) {
|
||||
refreshAndSubscribe()
|
||||
}
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
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.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* 加密价差策略结算轮询服务
|
||||
* 定时扫描「状态成功但未结算」的触发记录,通过 Gamma 获取 conditionId、链上查询结算结果,计算收益并回写。
|
||||
* 实际成交价与成交量使用 Data API 的 activity 接口获取(getUserActivity),比 CLOB getOrder 更准确;失败时回退为触发时的 amountUsdc + 固定价 0.99。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailSettlementService(
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailSettlementService::class.java)
|
||||
|
||||
private val triggerFixedPrice = BigDecimal("0.99")
|
||||
private val pnlScale = 8
|
||||
|
||||
private val settlementScopeJob = SupervisorJob()
|
||||
private val settlementScope = CoroutineScope(Dispatchers.IO + settlementScopeJob)
|
||||
|
||||
/** 跟踪上一轮结算任务的 Job,防止并发执行(与 OrderStatusUpdateService 一致) */
|
||||
@Volatile
|
||||
private var settlementJob: Job? = null
|
||||
|
||||
/**
|
||||
* 定时轮询:每 10 秒执行一次。
|
||||
* 若上一轮任务仍在执行则跳过本次,避免并发重叠。
|
||||
*/
|
||||
@Scheduled(fixedDelay = 10_000)
|
||||
fun scheduledPollAndSettle() {
|
||||
val previousJob = settlementJob
|
||||
if (previousJob != null && previousJob.isActive) {
|
||||
logger.debug("上一轮加密价差策略结算任务仍在执行,跳过本次调度")
|
||||
return
|
||||
}
|
||||
settlementJob = settlementScope.launch {
|
||||
try {
|
||||
doPollAndSettle()
|
||||
} catch (e: Exception) {
|
||||
logger.error("加密价差策略结算定时任务异常: ${e.message}", e)
|
||||
} finally {
|
||||
settlementJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询入口:拉取所有 status=success 且 resolved=false 的触发记录,逐条尝试结算并更新。
|
||||
* Controller/定时任务调用此方法(内部对 suspend 使用 runBlocking)。
|
||||
*/
|
||||
@Transactional
|
||||
fun pollAndSettle(): Int = runBlocking {
|
||||
doPollAndSettle()
|
||||
}
|
||||
|
||||
private suspend fun doPollAndSettle(): Int {
|
||||
val pending = triggerRepository.findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc("success", false)
|
||||
if (pending.isEmpty()) return 0
|
||||
var settledCount = 0
|
||||
for (trigger in pending) {
|
||||
try {
|
||||
if (settleOne(trigger)) settledCount++
|
||||
} catch (e: Exception) {
|
||||
logger.warn("加密价差策略结算单条失败: triggerId=${trigger.id}, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
if (settledCount > 0) {
|
||||
logger.info("加密价差策略结算轮询完成: 处理=${pending.size}, 新结算=$settledCount")
|
||||
}
|
||||
return settledCount
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单条触发记录:解析 conditionId -> 查链上结算 -> 若已结算则计算 pnl 并更新。
|
||||
* 通过 copy() 生成新实体再 save,不直接修改原实体;实际成交价与投入金额从 Data API activity 获取并更新 triggerPrice、amountUsdc。
|
||||
* @return true 表示本条已结算并更新
|
||||
*/
|
||||
private suspend fun settleOne(trigger: CryptoTailStrategyTrigger): Boolean {
|
||||
if (trigger.resolved) return false
|
||||
val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return false
|
||||
val conditionId = resolveConditionId(strategy, trigger) ?: return false
|
||||
val fill = fetchActivityFill(trigger, strategy, conditionId)
|
||||
val (newTriggerPrice, newAmountUsdc) = if (fill != null && fill.price.gt(BigDecimal.ZERO) && fill.size.gt(BigDecimal.ZERO)) {
|
||||
val amountUsdc = fill.usdcSize?.takeIf { it.gt(BigDecimal.ZERO) }
|
||||
?: fill.price.multi(fill.size).setScale(pnlScale, RoundingMode.HALF_UP)
|
||||
Pair(fill.price, amountUsdc)
|
||||
} else {
|
||||
Pair(trigger.triggerPrice, trigger.amountUsdc)
|
||||
}
|
||||
|
||||
val (_, payouts) = blockchainService.getCondition(conditionId).getOrNull() ?: run {
|
||||
if (fill != null) {
|
||||
val updated = trigger.copy(triggerPrice = newTriggerPrice, amountUsdc = newAmountUsdc)
|
||||
triggerRepository.save(updated)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (payouts.isEmpty()) {
|
||||
if (fill != null) {
|
||||
val updated = trigger.copy(triggerPrice = newTriggerPrice, amountUsdc = newAmountUsdc)
|
||||
triggerRepository.save(updated)
|
||||
}
|
||||
return false
|
||||
}
|
||||
val winnerIndex = payouts.indexOfFirst { it == java.math.BigInteger.ONE }
|
||||
if (winnerIndex < 0) return false
|
||||
|
||||
val won = trigger.outcomeIndex == winnerIndex
|
||||
val pnl = if (fill != null && fill.price.gt(BigDecimal.ZERO) && fill.size.gt(BigDecimal.ZERO)) {
|
||||
if (won) newAmountUsdc.let { fill.size.subtract(it).setScale(pnlScale, RoundingMode.HALF_UP) }
|
||||
else newAmountUsdc.negate().setScale(pnlScale, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
computePnlFallback(trigger.amountUsdc, won)
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val updated = trigger.copy(
|
||||
triggerPrice = newTriggerPrice,
|
||||
amountUsdc = newAmountUsdc,
|
||||
conditionId = conditionId,
|
||||
resolved = true,
|
||||
winnerOutcomeIndex = winnerIndex,
|
||||
realizedPnl = pnl,
|
||||
settledAt = now
|
||||
)
|
||||
triggerRepository.save(updated)
|
||||
logger.debug("加密价差策略结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl")
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun resolveConditionId(strategy: CryptoTailStrategy, trigger: CryptoTailStrategyTrigger): String? {
|
||||
if (!trigger.conditionId.isNullOrBlank()) return trigger.conditionId
|
||||
val slug = "${strategy.marketSlugPrefix}-${trigger.periodStartUnix}"
|
||||
val event = fetchEventBySlug(slug).getOrNull() ?: return null
|
||||
val markets = event.markets ?: return null
|
||||
val first = markets.firstOrNull() ?: return null
|
||||
return first.conditionId?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.getEventBySlug(slug)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
val msg = if (response.code() == 404) "404" else "code=${response.code()}"
|
||||
Result.failure(Exception(msg))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity 匹配到的一条 TRADE 的成交数据:价格、数量、实际投入 USDC(接口 usdcSize)。
|
||||
*/
|
||||
private data class ActivityFill(
|
||||
val price: BigDecimal,
|
||||
val size: BigDecimal,
|
||||
val usdcSize: BigDecimal?
|
||||
)
|
||||
|
||||
/**
|
||||
* 通过 Data API activity 接口获取该触发对应的实际成交价、成交量与投入金额(比 CLOB getOrder 更准确)。
|
||||
* 只有此接口返回匹配的 TRADE 且 price/size 有效时,结算才会更新 triggerPrice、amountUsdc(表现);投入金额优先用 activity 的 usdcSize。
|
||||
*/
|
||||
private suspend fun fetchActivityFill(
|
||||
trigger: CryptoTailStrategyTrigger,
|
||||
strategy: CryptoTailStrategy,
|
||||
conditionId: String
|
||||
): ActivityFill? {
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
||||
logger.warn("加密价差策略结算未拉取 activity: 账户不存在, triggerId=${trigger.id}, accountId=${strategy.accountId}")
|
||||
return null
|
||||
}
|
||||
val user = account.proxyAddress
|
||||
val triggerTimeSeconds = trigger.createdAt / 1000
|
||||
val start = triggerTimeSeconds - 120
|
||||
val end = triggerTimeSeconds + 600
|
||||
return try {
|
||||
val dataApi = retrofitFactory.createDataApi()
|
||||
val response = dataApi.getUserActivity(
|
||||
user = user,
|
||||
type = listOf("TRADE"),
|
||||
start = start,
|
||||
end = end,
|
||||
limit = 50,
|
||||
sortBy = "TIMESTAMP",
|
||||
sortDirection = "DESC"
|
||||
)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
logger.warn("加密价差策略结算拉取 activity 失败: triggerId=${trigger.id}, code=${response.code()}")
|
||||
return null
|
||||
}
|
||||
val activities = response.body()!!
|
||||
// 只匹配 TRADE:返回里可能混有 REDEEM(outcomeIndex=999、price=0)等,需排除
|
||||
val match = activities.firstOrNull { a ->
|
||||
a.type == "TRADE" &&
|
||||
a.conditionId == conditionId &&
|
||||
a.outcomeIndex != null && a.outcomeIndex in 0..1 &&
|
||||
a.outcomeIndex == trigger.outcomeIndex &&
|
||||
a.side?.uppercase() == "BUY" &&
|
||||
a.price != null && a.price > 0 &&
|
||||
a.size != null && a.size > 0
|
||||
} ?: run {
|
||||
logger.debug("加密价差策略结算 activity 无匹配成交: triggerId=${trigger.id}, conditionId=$conditionId, outcomeIndex=${trigger.outcomeIndex}, 条数=${activities.size}")
|
||||
return null
|
||||
}
|
||||
val price = match.price!!.toSafeBigDecimal()
|
||||
val size = match.size!!.toSafeBigDecimal()
|
||||
val usdcSize = match.usdcSize?.toSafeBigDecimal()?.takeIf { it.gt(BigDecimal.ZERO) }
|
||||
if (price.gt(BigDecimal.ZERO) && size.gt(BigDecimal.ZERO)) {
|
||||
ActivityFill(price = price, size = size, usdcSize = usdcSize)
|
||||
} else {
|
||||
logger.debug("加密价差策略结算 activity 成交数据无效: triggerId=${trigger.id}, price=$price, size=$size")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("加密价差策略结算拉取 activity 异常,触发价/投入金额不会更新: triggerId=${trigger.id}, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按实际成交价与成交量计算收益:成本 = sizeMatched * price;赢则赎回 sizeMatched * 1,输则 0。
|
||||
*/
|
||||
private fun computePnlFromFill(price: BigDecimal, sizeMatched: BigDecimal, won: Boolean): BigDecimal {
|
||||
val cost = sizeMatched.multi(price).setScale(pnlScale, RoundingMode.HALF_UP)
|
||||
return if (won) {
|
||||
sizeMatched.subtract(cost).setScale(pnlScale, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
cost.negate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退收益计算:无 API 数据时用触发时的 amountUsdc 与固定价 0.99。
|
||||
* 赢: pnl = amountUsdc/0.99 - amountUsdc;输: pnl = -amountUsdc
|
||||
*/
|
||||
private fun computePnlFallback(amountUsdc: BigDecimal, won: Boolean): BigDecimal {
|
||||
return if (won) {
|
||||
amountUsdc.divide(triggerFixedPrice, pnlScale, RoundingMode.HALF_UP).subtract(amountUsdc)
|
||||
} else {
|
||||
amountUsdc.negate()
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
settlementJob?.cancel()
|
||||
settlementJob = null
|
||||
settlementScopeJob.cancel()
|
||||
}
|
||||
}
|
||||
+842
@@ -0,0 +1,842 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.api.NewOrderRequest
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse
|
||||
import com.wrbug.polymarketbot.dto.ManualOrderDetails
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||
import com.wrbug.polymarketbot.enums.SpreadDirection
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.div
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.github.benmanes.caffeine.cache.Cache
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/** 加密价差策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
|
||||
private const val TRIGGER_FIXED_PRICE = "0.99"
|
||||
|
||||
/** 最大价差模式(MAX)时,买入价格调整系数(加在触发价格上) */
|
||||
private const val SPREAD_MAX_PRICE_ADJUSTMENT = "0.02"
|
||||
|
||||
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
|
||||
private const val SIZE_DECIMAL_SCALE = 2
|
||||
|
||||
/** 单笔下单最小 USDC 金额(平台限制),RATIO 模式计算值低于此值时按此值下单 */
|
||||
private val MIN_ORDER_USDC = BigDecimal("1")
|
||||
|
||||
/**
|
||||
* 周期内预置上下文:账户、解密凭证、费率、签名类型、CLOB 客户端;不含预签订单。
|
||||
* 触发时 FIXED/RATIO 均按 outcomeIndex 计算 size 并签名提交。
|
||||
*/
|
||||
private data class PeriodContext(
|
||||
val strategy: CryptoTailStrategy,
|
||||
val periodStartUnix: Long,
|
||||
val account: Account,
|
||||
val decryptedPrivateKey: String,
|
||||
val apiSecretDecrypted: String,
|
||||
val apiPassphraseDecrypted: String,
|
||||
val clobApi: PolymarketClobApi,
|
||||
val signatureType: Int,
|
||||
val tokenIds: List<String>,
|
||||
val marketTitle: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* 加密价差策略执行服务:按周期与时间窗口检查价格并下单,每周期最多触发一次。
|
||||
* 周期开始预置账户、解密、费率、签名类型、CLOB 客户端;触发时按 outcomeIndex 计算 size 并签名提交。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailStrategyExecutionService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val accountService: AccountService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val clobService: PolymarketClobService,
|
||||
private val orderSigningService: OrderSigningService,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val binanceKlineService: BinanceKlineService,
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailStrategyExecutionService::class.java)
|
||||
|
||||
/** 按 (strategyId, periodStartUnix) 加锁,避免同一周期被调度器与 WebSocket 等多路并发重复下单 */
|
||||
private val triggerMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
/** 过期锁 key 保留时间(秒),超过则清理,防止 map 无界增长 */
|
||||
private val triggerMutexExpireSeconds = 3600L
|
||||
|
||||
private fun triggerLockKey(strategyId: Long, periodStartUnix: Long): String = "$strategyId-$periodStartUnix"
|
||||
|
||||
private fun getTriggerMutex(strategyId: Long, periodStartUnix: Long): Mutex {
|
||||
cleanExpiredTriggerMutexKeys()
|
||||
return triggerMutexMap.getOrPut(triggerLockKey(strategyId, periodStartUnix)) { Mutex() }
|
||||
}
|
||||
|
||||
/** 清理已过期的 (strategyId, periodStartUnix) 锁,避免内存泄漏 */
|
||||
private fun cleanExpiredTriggerMutexKeys() {
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val expireThreshold = nowSeconds - triggerMutexExpireSeconds
|
||||
val keysToRemove = triggerMutexMap.keys.filter { key ->
|
||||
key.substringAfterLast('-').toLongOrNull()?.let { it < expireThreshold } ?: false
|
||||
}
|
||||
keysToRemove.forEach { triggerMutexMap.remove(it) }
|
||||
}
|
||||
|
||||
/** 周期预置上下文缓存:(strategyId-periodStartUnix) -> PeriodContext,过期周期在读取时剔除 */
|
||||
private val periodContextCache = ConcurrentHashMap<String, PeriodContext>()
|
||||
|
||||
/** 已打印「首次满足条件」日志的周期:LRU 容量 100,每周期只打一次 */
|
||||
private val conditionLoggedCache: Cache<String, Long> = Caffeine.newBuilder()
|
||||
.maximumSize(100)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* 在周期内首次需要时构建并缓存预置上下文;失败返回 null,触发流程将走完整路径。
|
||||
* 预置:账户、解密、费率、签名类型、CLOB 客户端;不预签订单,触发时再签名。
|
||||
*/
|
||||
private suspend fun ensurePeriodContext(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
tokenIds: List<String>,
|
||||
marketTitle: String?
|
||||
): PeriodContext? {
|
||||
val key = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||
periodContextCache[key]?.let { return it }
|
||||
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: return null
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) return null
|
||||
|
||||
val decryptedKey = try {
|
||||
cryptoUtils.decrypt(account.privateKey) ?: return null
|
||||
} catch (e: Exception) {
|
||||
logger.warn("加密价差策略周期上下文解密私钥失败: accountId=${account.id}", e)
|
||||
return null
|
||||
}
|
||||
val apiSecret = try {
|
||||
account.apiSecret.let { cryptoUtils.decrypt(it) }
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
val apiPassphrase = try {
|
||||
account.apiPassphrase.let { cryptoUtils.decrypt(it) }
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress)
|
||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
|
||||
if (strategy.amountMode.uppercase() != "RATIO" && strategy.amountValue < MIN_ORDER_USDC) return null
|
||||
|
||||
val ctx = PeriodContext(
|
||||
strategy = strategy,
|
||||
periodStartUnix = periodStartUnix,
|
||||
account = account,
|
||||
decryptedPrivateKey = decryptedKey,
|
||||
apiSecretDecrypted = apiSecret,
|
||||
apiPassphraseDecrypted = apiPassphrase,
|
||||
clobApi = clobApi,
|
||||
signatureType = signatureType,
|
||||
tokenIds = tokenIds,
|
||||
marketTitle = marketTitle
|
||||
)
|
||||
periodContextCache[key] = ctx
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* 按投入金额和价格计算可买张数:size = ceil(amountUsdc/price),保留小数,至少 1。
|
||||
* 与 OrderSigningService 一致使用小数数量,向上取整保证不超过投入金额。
|
||||
*/
|
||||
private fun computeSize(amountUsdc: BigDecimal, price: BigDecimal): String {
|
||||
val size = amountUsdc.divide(price, SIZE_DECIMAL_SCALE, RoundingMode.UP).max(BigDecimal.ONE)
|
||||
return size.toPlainString()
|
||||
}
|
||||
|
||||
private fun getOrInvalidatePeriodContext(strategy: CryptoTailStrategy, periodStartUnix: Long): PeriodContext? {
|
||||
val key = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val ctx = periodContextCache[key] ?: return null
|
||||
if (periodStartUnix + strategy.intervalSeconds <= nowSeconds) {
|
||||
periodContextCache.remove(key)
|
||||
cleanExpiredPeriodContextCache(nowSeconds)
|
||||
return null
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** 清理已过期的周期上下文缓存,避免内存泄漏 */
|
||||
private fun cleanExpiredPeriodContextCache(nowSeconds: Long) {
|
||||
val keysToRemove = periodContextCache.entries
|
||||
.filter { (_, ctx) -> ctx.periodStartUnix + ctx.strategy.intervalSeconds <= nowSeconds }
|
||||
.map { it.key }
|
||||
keysToRemove.forEach { periodContextCache.remove(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 由订单簿 WebSocket 触发:当收到某 token 的 bestBid 且满足区间时调用,若本周期未触发则下单。
|
||||
*/
|
||||
suspend fun tryTriggerWithPriceFromWs(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
tokenIds: List<String>,
|
||||
outcomeIndex: Int,
|
||||
bestBid: BigDecimal
|
||||
) {
|
||||
if (outcomeIndex < 0 || outcomeIndex >= tokenIds.size) return
|
||||
if (bestBid < strategy.minPrice || bestBid > strategy.maxPrice) return
|
||||
|
||||
val mutex = getTriggerMutex(strategy.id!!, periodStartUnix)
|
||||
mutex.withLock {
|
||||
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(
|
||||
strategy.id!!,
|
||||
periodStartUnix
|
||||
) != null
|
||||
) return@withLock
|
||||
val logKey = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||
if (conditionLoggedCache.getIfPresent(logKey) == null) {
|
||||
conditionLoggedCache.put(logKey, periodStartUnix + strategy.intervalSeconds)
|
||||
val oc = binanceKlineService.getCurrentOpenClose(
|
||||
strategy.marketSlugPrefix,
|
||||
strategy.intervalSeconds,
|
||||
periodStartUnix
|
||||
)
|
||||
val openPrice = oc?.first?.toPlainString() ?: "-"
|
||||
val closePrice = oc?.second?.toPlainString() ?: "-"
|
||||
val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "加密价差策略-${strategy.marketSlugPrefix}"
|
||||
val direction = if (outcomeIndex == 0) "Up" else "Down"
|
||||
val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差"
|
||||
logger.info(
|
||||
"加密价差策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
|
||||
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
|
||||
"direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr"
|
||||
)
|
||||
}
|
||||
if (!passSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
|
||||
ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle)
|
||||
placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
|
||||
if (strategy.spreadMode == SpreadMode.NONE) return true
|
||||
val oc = binanceKlineService.getCurrentOpenClose(
|
||||
strategy.marketSlugPrefix,
|
||||
strategy.intervalSeconds,
|
||||
periodStartUnix
|
||||
)
|
||||
?: return false
|
||||
val (openP, closeP) = oc
|
||||
val spreadAbs = closeP.subtract(openP).abs()
|
||||
|
||||
// 获取有效价差
|
||||
val effectiveSpread = when (strategy.spreadMode) {
|
||||
SpreadMode.FIXED -> {
|
||||
strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true
|
||||
}
|
||||
|
||||
SpreadMode.AUTO -> {
|
||||
val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
|
||||
result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true
|
||||
}
|
||||
|
||||
SpreadMode.NONE -> return true
|
||||
}
|
||||
|
||||
// 根据价差方向判断
|
||||
return if (strategy.spreadDirection == SpreadDirection.MAX) {
|
||||
// 最大价差模式:价差 <= 配置值时触发
|
||||
spreadAbs <= effectiveSpread
|
||||
} else {
|
||||
// 最小价差模式:价差 >= 配置值时触发
|
||||
spreadAbs >= effectiveSpread
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTO 模式:取 100% 基准价差,按窗口内毫秒进度计算动态系数(100%→50%)得到有效价差。
|
||||
*/
|
||||
private data class AutoSpreadResult(
|
||||
val baseSpread: BigDecimal,
|
||||
val coefficient: BigDecimal,
|
||||
val effectiveSpread: BigDecimal
|
||||
)
|
||||
|
||||
private fun computeAutoEffectiveSpread(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
outcomeIndex: Int
|
||||
): AutoSpreadResult? {
|
||||
val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(
|
||||
strategy.marketSlugPrefix,
|
||||
strategy.intervalSeconds,
|
||||
periodStartUnix,
|
||||
outcomeIndex
|
||||
)
|
||||
?: binanceKlineAutoSpreadService.computeAndCache(
|
||||
strategy.marketSlugPrefix,
|
||||
strategy.intervalSeconds,
|
||||
periodStartUnix
|
||||
)?.let { if (outcomeIndex == 0) it.first else it.second }
|
||||
?: return null
|
||||
if (baseSpread <= BigDecimal.ZERO) return null
|
||||
val windowStartMs = (periodStartUnix + strategy.windowStartSeconds) * 1000L
|
||||
val windowEndMs = (periodStartUnix + strategy.windowEndSeconds) * 1000L
|
||||
val windowLenMs = windowEndMs - windowStartMs
|
||||
val coefficient = if (windowLenMs <= 0) {
|
||||
BigDecimal.ONE
|
||||
} else {
|
||||
val nowMs = System.currentTimeMillis()
|
||||
val elapsedMs = (nowMs - windowStartMs).toBigDecimal()
|
||||
val progress = elapsedMs.div(windowLenMs.toBigDecimal(), 18, RoundingMode.HALF_UP)
|
||||
.let { p -> maxOf(BigDecimal.ZERO, minOf(BigDecimal.ONE, p)) }
|
||||
BigDecimal.ONE.subtract(progress.multi("0.5"))
|
||||
}
|
||||
val effectiveSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
|
||||
return AutoSpreadResult(baseSpread, coefficient, effectiveSpread)
|
||||
}
|
||||
|
||||
private suspend fun placeOrderForTrigger(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
tokenIds: List<String>,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal
|
||||
) {
|
||||
val ctx = getOrInvalidatePeriodContext(strategy, periodStartUnix)
|
||||
|
||||
if (ctx != null) {
|
||||
var availableBalanceForRatio = BigDecimal.ZERO
|
||||
var amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||
"RATIO" -> {
|
||||
val balanceResult = accountService.getAccountBalance(ctx.account.id)
|
||||
val availableBalance =
|
||||
balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
availableBalanceForRatio = availableBalance
|
||||
availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
else -> strategy.amountValue
|
||||
}
|
||||
if (amountUsdc < MIN_ORDER_USDC) {
|
||||
val amountMode = strategy.amountMode.uppercase()
|
||||
if (amountMode == "RATIO" && availableBalanceForRatio >= MIN_ORDER_USDC) {
|
||||
amountUsdc = MIN_ORDER_USDC
|
||||
} else {
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
null,
|
||||
"fail",
|
||||
"投入金额不足"
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
null,
|
||||
"fail",
|
||||
"tokenIds 越界"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据价差方向确定下单价格
|
||||
val price = if (strategy.spreadDirection == SpreadDirection.MAX) {
|
||||
// 最大价差模式:触发价格 + 0.02
|
||||
triggerPrice.add(BigDecimal(SPREAD_MAX_PRICE_ADJUSTMENT)).setScale(8, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
// 最小价差模式:固定价格 0.99
|
||||
BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
}
|
||||
val priceStr = price.toPlainString()
|
||||
val size = computeSize(amountUsdc, price)
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = ctx.decryptedPrivateKey,
|
||||
makerAddress = ctx.account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = priceStr,
|
||||
size = size,
|
||||
signatureType = ctx.signatureType
|
||||
)
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = ctx.account.apiKey!!,
|
||||
orderType = "FAK"
|
||||
)
|
||||
submitOrderAndSaveRecord(
|
||||
ctx.clobApi,
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
orderRequest,
|
||||
triggerType = "AUTO"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
placeOrderForTriggerSlowPath(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, triggerPrice)
|
||||
}
|
||||
|
||||
private suspend fun submitOrderAndSaveRecord(
|
||||
clobApi: PolymarketClobApi,
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal,
|
||||
amountUsdc: BigDecimal,
|
||||
orderRequest: NewOrderRequest,
|
||||
triggerType: String = "AUTO"
|
||||
) {
|
||||
var failReason: String? = null
|
||||
try {
|
||||
val response = clobApi.createOrder(orderRequest)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val body = response.body()!!
|
||||
if (body.success && body.orderId != null) {
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
body.orderId,
|
||||
"success",
|
||||
null,
|
||||
triggerType = triggerType
|
||||
)
|
||||
logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}, triggerType=$triggerType")
|
||||
return
|
||||
}
|
||||
failReason = body.errorMsg ?: "unknown"
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
failReason = errorBody.ifEmpty { "请求失败" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failReason = e.message ?: e.toString()
|
||||
logger.error("加密价差策略下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
|
||||
}
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
null,
|
||||
"fail",
|
||||
failReason,
|
||||
triggerType = triggerType
|
||||
)
|
||||
logger.error("加密价差策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason")
|
||||
}
|
||||
|
||||
/** 无预置上下文时的完整流程:固定价格 0.99,账户/解密/费率/签名在触发时执行 */
|
||||
private suspend fun placeOrderForTriggerSlowPath(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
tokenIds: List<String>,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal
|
||||
) {
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
||||
logger.warn("账户不存在: accountId=${strategy.accountId}")
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
BigDecimal.ZERO,
|
||||
null,
|
||||
"fail",
|
||||
"账户不存在"
|
||||
)
|
||||
return
|
||||
}
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.warn("账户未配置 API 凭证: accountId=${account.id}")
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
BigDecimal.ZERO,
|
||||
null,
|
||||
"fail",
|
||||
"账户未配置API凭证"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val balanceResult = accountService.getAccountBalance(account.id)
|
||||
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
var amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||
"RATIO" -> availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
||||
else -> strategy.amountValue
|
||||
}
|
||||
if (amountUsdc < MIN_ORDER_USDC) {
|
||||
val amountMode = strategy.amountMode.uppercase()
|
||||
if (amountMode == "RATIO" && availableBalance >= MIN_ORDER_USDC) {
|
||||
amountUsdc = MIN_ORDER_USDC
|
||||
} else {
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
null,
|
||||
"fail",
|
||||
"投入金额不足"
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
null,
|
||||
"fail",
|
||||
"tokenIds 越界"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据价差方向确定下单价格
|
||||
val price = if (strategy.spreadDirection == SpreadDirection.MAX) {
|
||||
// 最大价差模式:触发价格 + 0.02
|
||||
triggerPrice.add(BigDecimal(SPREAD_MAX_PRICE_ADJUSTMENT)).setScale(8, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
// 最小价差模式:固定价格 0.99
|
||||
BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
}
|
||||
val priceStr = price.toPlainString()
|
||||
val size = computeSize(amountUsdc, price)
|
||||
|
||||
val decryptedKey = try {
|
||||
cryptoUtils.decrypt(account.privateKey) ?: ""
|
||||
} catch (e: Exception) {
|
||||
logger.error("解密私钥失败: accountId=${account.id}", e)
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
null,
|
||||
"fail",
|
||||
"解密私钥失败"
|
||||
)
|
||||
return
|
||||
}
|
||||
val apiSecret = try {
|
||||
account.apiSecret.let { cryptoUtils.decrypt(it) }
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
val apiPassphrase = try {
|
||||
account.apiPassphrase.let { cryptoUtils.decrypt(it) }
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress)
|
||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = priceStr,
|
||||
size = size,
|
||||
signatureType = signatureType
|
||||
)
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey!!,
|
||||
orderType = "FAK"
|
||||
)
|
||||
submitOrderAndSaveRecord(
|
||||
clobApi,
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
orderRequest
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.getEventBySlug(slug)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
val msg = if (response.code() == 404) "404" else "code=${response.code()}"
|
||||
Result.failure(Exception(msg))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
|
||||
if (clobTokenIds.isNullOrBlank()) return emptyList()
|
||||
val parsed = clobTokenIds.fromJson<List<String>>()
|
||||
return parsed ?: emptyList()
|
||||
}
|
||||
|
||||
private fun saveTriggerRecord(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal,
|
||||
amountUsdc: BigDecimal,
|
||||
orderId: String?,
|
||||
status: String,
|
||||
failReason: String?,
|
||||
triggerType: String = "AUTO"
|
||||
) {
|
||||
val record = CryptoTailStrategyTrigger(
|
||||
strategyId = strategy.id!!,
|
||||
periodStartUnix = periodStartUnix,
|
||||
marketTitle = marketTitle,
|
||||
outcomeIndex = outcomeIndex,
|
||||
triggerPrice = triggerPrice,
|
||||
amountUsdc = amountUsdc,
|
||||
orderId = orderId,
|
||||
status = status,
|
||||
failReason = failReason,
|
||||
triggerType = triggerType
|
||||
)
|
||||
triggerRepository.save(record)
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动下单:用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单
|
||||
*/
|
||||
suspend fun manualOrder(request: CryptoTailManualOrderRequest): Result<CryptoTailManualOrderResponse> {
|
||||
return try {
|
||||
val strategy = strategyRepository.findById(request.strategyId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("策略不存在"))
|
||||
|
||||
val outcomeIndex = if (request.direction.uppercase() == "UP") 0 else 1
|
||||
|
||||
if (outcomeIndex < 0 || outcomeIndex >= request.tokenIds.size) {
|
||||
return Result.failure(IllegalArgumentException("outcomeIndex 越界"))
|
||||
}
|
||||
|
||||
val price = request.price.toSafeBigDecimal()
|
||||
if (price <= BigDecimal.ZERO || price > BigDecimal.ONE) {
|
||||
return Result.failure(IllegalArgumentException("价格必须在 0~1 之间"))
|
||||
}
|
||||
val priceRounded = price.setScale(4, RoundingMode.UP)
|
||||
|
||||
val size = request.size.toSafeBigDecimal()
|
||||
if (size < BigDecimal.ONE) {
|
||||
return Result.failure(IllegalArgumentException("数量不能少于 1"))
|
||||
}
|
||||
|
||||
val amountUsdc = priceRounded.multi(size).setScale(2, RoundingMode.HALF_UP)
|
||||
if (amountUsdc < BigDecimal.ONE) {
|
||||
return Result.failure(IllegalArgumentException("总金额不能少于 \$1"))
|
||||
}
|
||||
|
||||
val mutex = getTriggerMutex(strategy.id!!, request.periodStartUnix)
|
||||
mutex.withLock {
|
||||
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(
|
||||
strategy.id!!,
|
||||
request.periodStartUnix
|
||||
) != null
|
||||
) {
|
||||
return@withLock Result.failure(IllegalArgumentException("当前周期已下单"))
|
||||
}
|
||||
|
||||
var ctx = getOrInvalidatePeriodContext(strategy, request.periodStartUnix)
|
||||
if (ctx == null) {
|
||||
ctx = ensurePeriodContext(
|
||||
strategy,
|
||||
request.periodStartUnix,
|
||||
request.tokenIds,
|
||||
request.marketTitle.ifBlank { null }
|
||||
)
|
||||
}
|
||||
if (ctx != null) {
|
||||
val tokenId = request.tokenIds.getOrNull(outcomeIndex)
|
||||
?: return@withLock Result.failure(IllegalArgumentException("tokenIds 越界"))
|
||||
|
||||
val priceStr = priceRounded.toPlainString()
|
||||
val sizeStr = size.toPlainString()
|
||||
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = ctx.decryptedPrivateKey,
|
||||
makerAddress = ctx.account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = priceStr,
|
||||
size = sizeStr,
|
||||
signatureType = ctx.signatureType
|
||||
)
|
||||
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = ctx.account.apiKey!!,
|
||||
orderType = "FAK"
|
||||
)
|
||||
|
||||
val orderResult = submitOrderForManualOrder(
|
||||
ctx.clobApi,
|
||||
strategy,
|
||||
request.periodStartUnix,
|
||||
request.marketTitle,
|
||||
outcomeIndex,
|
||||
priceRounded,
|
||||
amountUsdc,
|
||||
orderRequest
|
||||
)
|
||||
|
||||
orderResult.fold(
|
||||
onSuccess = { orderId ->
|
||||
Result.success(
|
||||
CryptoTailManualOrderResponse(
|
||||
success = true,
|
||||
orderId = orderId,
|
||||
message = "下单成功",
|
||||
orderDetails = ManualOrderDetails(
|
||||
strategyId = strategy.id!!,
|
||||
direction = request.direction,
|
||||
price = priceStr,
|
||||
size = sizeStr,
|
||||
totalAmount = amountUsdc.toPlainString()
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Result.failure(IllegalArgumentException("账户未配置或凭证不足"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("手动下单异常: strategyId=${request.strategyId}, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun submitOrderForManualOrder(
|
||||
clobApi: PolymarketClobApi,
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
outcomeIndex: Int,
|
||||
price: BigDecimal,
|
||||
amountUsdc: BigDecimal,
|
||||
orderRequest: NewOrderRequest
|
||||
): Result<String> {
|
||||
return try {
|
||||
val response = clobApi.createOrder(orderRequest)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val body = response.body()!!
|
||||
if (body.success && body.orderId != null) {
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
price,
|
||||
amountUsdc,
|
||||
body.orderId,
|
||||
"success",
|
||||
null,
|
||||
triggerType = "MANUAL"
|
||||
)
|
||||
logger.info("手动下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
|
||||
Result.success(body.orderId)
|
||||
} else {
|
||||
Result.failure(Exception(body.errorMsg ?: "unknown"))
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
Result.failure(Exception(errorBody.ifEmpty { "请求失败" }))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("手动下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
// 清理所有周期上下文缓存,避免敏感信息(明文私钥、API Secret)在内存中保留
|
||||
periodContextCache.clear()
|
||||
// 清理所有锁,避免内存泄漏
|
||||
triggerMutexMap.clear()
|
||||
logger.debug("加密价差策略执行服务已清理缓存和锁")
|
||||
}
|
||||
}
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||
import com.wrbug.polymarketbot.enums.SpreadDirection
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Service
|
||||
class CryptoTailStrategyService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailStrategyService::class.java)
|
||||
|
||||
private val maxWindowByInterval = mapOf(300 to 300, 900 to 900)
|
||||
|
||||
@Transactional
|
||||
fun create(request: CryptoTailStrategyCreateRequest): Result<CryptoTailStrategyDto> {
|
||||
return try {
|
||||
if (request.accountId <= 0) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey))
|
||||
}
|
||||
if (request.marketSlugPrefix.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val interval = request.intervalSeconds
|
||||
if (interval != 300 && interval != 900) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID.messageKey))
|
||||
}
|
||||
val maxWindow = maxWindowByInterval[interval] ?: 300
|
||||
if (request.windowStartSeconds > request.windowEndSeconds) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey))
|
||||
}
|
||||
if (request.windowEndSeconds > maxWindow) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey))
|
||||
}
|
||||
val amountMode = request.amountMode.uppercase()
|
||||
if (amountMode != "RATIO" && amountMode != "FIXED") {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey))
|
||||
}
|
||||
val minPrice = request.minPrice.toSafeBigDecimal()
|
||||
val maxPrice = (request.maxPrice ?: "1").toSafeBigDecimal()
|
||||
if (minPrice > maxPrice) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val amountValue = request.amountValue.toSafeBigDecimal()
|
||||
if (amountValue <= BigDecimal.ZERO) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val spreadMode = try {
|
||||
SpreadMode.fromString(request.spreadMode)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val spreadValue = request.spreadValue?.toSafeBigDecimal()
|
||||
if (spreadMode == SpreadMode.FIXED && (spreadValue == null || spreadValue < BigDecimal.ZERO)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val spreadDirection = try {
|
||||
SpreadDirection.fromString(request.spreadDirection)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
|
||||
val nameToSave = request.name?.takeIf { it.isNotBlank() }
|
||||
?: generateStrategyName(request.marketSlugPrefix.trim())
|
||||
|
||||
val entity = CryptoTailStrategy(
|
||||
accountId = request.accountId,
|
||||
name = nameToSave,
|
||||
marketSlugPrefix = request.marketSlugPrefix.trim(),
|
||||
intervalSeconds = interval,
|
||||
windowStartSeconds = request.windowStartSeconds,
|
||||
windowEndSeconds = request.windowEndSeconds,
|
||||
minPrice = minPrice,
|
||||
maxPrice = maxPrice,
|
||||
amountMode = amountMode,
|
||||
amountValue = amountValue,
|
||||
spreadMode = spreadMode,
|
||||
spreadValue = spreadValue,
|
||||
spreadDirection = spreadDirection,
|
||||
enabled = request.enabled
|
||||
)
|
||||
val saved = strategyRepository.save(entity)
|
||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||
Result.success(entityToDto(saved, null))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建加密价差策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(request: CryptoTailStrategyUpdateRequest): Result<CryptoTailStrategyDto> {
|
||||
return try {
|
||||
val existing = strategyRepository.findById(request.strategyId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
|
||||
val interval = existing.intervalSeconds
|
||||
val maxWindow = maxWindowByInterval[interval] ?: 300
|
||||
|
||||
request.windowStartSeconds?.let { ws ->
|
||||
request.windowEndSeconds?.let { we ->
|
||||
if (ws > we) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey))
|
||||
if (we > maxWindow) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey))
|
||||
}
|
||||
}
|
||||
request.windowStartSeconds?.let { if (it > (request.windowEndSeconds ?: existing.windowEndSeconds)) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey)) }
|
||||
request.windowEndSeconds?.let { if (it > maxWindow) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey)) }
|
||||
|
||||
val nameToSave = request.name?.takeIf { it.isNotBlank() }
|
||||
?: existing.name?.takeIf { it.isNotBlank() }
|
||||
?: generateStrategyName(existing.marketSlugPrefix)
|
||||
|
||||
val newSpreadMode = if (request.spreadMode != null) {
|
||||
try {
|
||||
SpreadMode.fromString(request.spreadMode)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
} else {
|
||||
existing.spreadMode
|
||||
}
|
||||
val newSpreadValue = request.spreadValue?.toSafeBigDecimal() ?: existing.spreadValue
|
||||
if (newSpreadMode == SpreadMode.FIXED && (newSpreadValue == null || newSpreadValue < BigDecimal.ZERO)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val newSpreadDirection = if (request.spreadDirection != null) {
|
||||
try {
|
||||
SpreadDirection.fromString(request.spreadDirection)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
} else {
|
||||
existing.spreadDirection
|
||||
}
|
||||
|
||||
val updated = existing.copy(
|
||||
name = nameToSave,
|
||||
windowStartSeconds = request.windowStartSeconds ?: existing.windowStartSeconds,
|
||||
windowEndSeconds = request.windowEndSeconds ?: existing.windowEndSeconds,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: existing.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice,
|
||||
amountMode = request.amountMode?.uppercase() ?: existing.amountMode,
|
||||
amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue,
|
||||
spreadMode = newSpreadMode,
|
||||
spreadValue = newSpreadValue,
|
||||
spreadDirection = newSpreadDirection,
|
||||
enabled = request.enabled ?: existing.enabled,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
if (updated.minPrice > updated.maxPrice) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
request.amountMode?.uppercase()?.let { if (it != "RATIO" && it != "FIXED") return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey)) }
|
||||
val saved = strategyRepository.save(updated)
|
||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||
val lastTrigger = triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(saved.id!!, PageRequest.of(0, 1))
|
||||
.content.firstOrNull()?.createdAt
|
||||
Result.success(entityToDto(saved, lastTrigger))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新加密价差策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun delete(strategyId: Long): Result<Unit> {
|
||||
return try {
|
||||
if (!strategyRepository.existsById(strategyId)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
|
||||
}
|
||||
strategyRepository.deleteById(strategyId)
|
||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除加密价差策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun list(request: CryptoTailStrategyListRequest): Result<CryptoTailStrategyListResponse> {
|
||||
return try {
|
||||
val list = when {
|
||||
request.accountId != null && request.enabled != null -> strategyRepository.findByAccountIdAndEnabled(request.accountId, request.enabled)
|
||||
request.accountId != null -> strategyRepository.findAllByAccountId(request.accountId)
|
||||
request.enabled == true -> strategyRepository.findAllByEnabledTrue()
|
||||
request.enabled == false -> strategyRepository.findAll().filter { !it.enabled }
|
||||
else -> strategyRepository.findAll()
|
||||
}
|
||||
val lastTriggerMap = list.map { it.id!! }.associateWith { id ->
|
||||
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(id, PageRequest.of(0, 1))
|
||||
.content.firstOrNull()?.createdAt
|
||||
}
|
||||
val dtos = list.map { entityToDto(it, lastTriggerMap[it.id]) }
|
||||
Result.success(CryptoTailStrategyListResponse(list = dtos))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询加密价差策略列表失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPnlCurve(request: CryptoTailPnlCurveRequest): Result<CryptoTailPnlCurveResponse> {
|
||||
return try {
|
||||
val strategy = strategyRepository.findById(request.strategyId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
|
||||
val start = request.startDate ?: 0L
|
||||
val end = request.endDate ?: Long.MAX_VALUE
|
||||
val triggers = triggerRepository.findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc(
|
||||
request.strategyId, start, end
|
||||
)
|
||||
var cumulative = BigDecimal.ZERO
|
||||
var peak = BigDecimal.ZERO
|
||||
var maxDrawdown = BigDecimal.ZERO
|
||||
var winCountInRange = 0L
|
||||
val curveData = triggers.map { t ->
|
||||
val pnl = t.realizedPnl ?: BigDecimal.ZERO
|
||||
cumulative = cumulative.add(pnl)
|
||||
if (cumulative.gt(peak)) peak = cumulative
|
||||
val drawdown = peak.subtract(cumulative)
|
||||
if (drawdown.gt(maxDrawdown)) maxDrawdown = drawdown
|
||||
if (t.winnerOutcomeIndex != null && t.outcomeIndex == t.winnerOutcomeIndex) winCountInRange++
|
||||
val ts = t.settledAt ?: t.createdAt
|
||||
CryptoTailPnlCurvePoint(
|
||||
timestamp = ts,
|
||||
cumulativePnl = cumulative.toPlainString(),
|
||||
pointPnl = pnl.toPlainString(),
|
||||
settledCount = 0L
|
||||
)
|
||||
}.mapIndexed { index, p ->
|
||||
p.copy(settledCount = (index + 1).toLong())
|
||||
}
|
||||
val totalPnl = if (curveData.isEmpty()) BigDecimal.ZERO else curveData.last().cumulativePnl.toSafeBigDecimal()
|
||||
val settledCountInRange = curveData.size.toLong()
|
||||
val winRateStr = if (settledCountInRange > 0L) {
|
||||
BigDecimal(winCountInRange).divide(BigDecimal(settledCountInRange), 4, java.math.RoundingMode.HALF_UP).toPlainString()
|
||||
} else null
|
||||
Result.success(
|
||||
CryptoTailPnlCurveResponse(
|
||||
strategyId = request.strategyId,
|
||||
strategyName = strategy.name ?: strategy.marketSlugPrefix,
|
||||
totalRealizedPnl = totalPnl.toPlainString(),
|
||||
settledCount = settledCountInRange,
|
||||
winCount = winCountInRange,
|
||||
winRate = winRateStr,
|
||||
maxDrawdown = if (maxDrawdown.compareTo(BigDecimal.ZERO) > 0) maxDrawdown.toPlainString() else null,
|
||||
curveData = curveData
|
||||
)
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询收益曲线失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTriggerRecords(request: CryptoTailStrategyTriggerListRequest): Result<CryptoTailStrategyTriggerListResponse> {
|
||||
return try {
|
||||
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
|
||||
val startTs = request.startDate ?: 0L
|
||||
val endTs = request.endDate ?: Long.MAX_VALUE
|
||||
val useTimeRange = request.startDate != null || request.endDate != null
|
||||
val pageResult = when {
|
||||
useTimeRange && request.status != null && request.status.isNotBlank() ->
|
||||
triggerRepository.findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(
|
||||
request.strategyId, request.status, startTs, endTs, page
|
||||
)
|
||||
useTimeRange ->
|
||||
triggerRepository.findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(
|
||||
request.strategyId, startTs, endTs, page
|
||||
)
|
||||
request.status != null && request.status.isNotBlank() ->
|
||||
triggerRepository.findAllByStrategyIdAndStatusOrderByCreatedAtDesc(request.strategyId, request.status, page)
|
||||
else ->
|
||||
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(request.strategyId, page)
|
||||
}
|
||||
val list = pageResult.content.map { triggerToDto(it) }
|
||||
val total = when {
|
||||
useTimeRange && request.status != null && request.status.isNotBlank() ->
|
||||
triggerRepository.countByStrategyIdAndStatusAndCreatedAtBetween(request.strategyId, request.status, startTs, endTs)
|
||||
useTimeRange ->
|
||||
triggerRepository.countByStrategyIdAndCreatedAtBetween(request.strategyId, startTs, endTs)
|
||||
request.status != null && request.status.isNotBlank() ->
|
||||
triggerRepository.countByStrategyIdAndStatus(request.strategyId, request.status)
|
||||
else ->
|
||||
pageResult.totalElements
|
||||
}
|
||||
Result.success(CryptoTailStrategyTriggerListResponse(list = list, total = total))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询触发记录失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getStrategy(strategyId: Long): CryptoTailStrategy? = strategyRepository.findById(strategyId).orElse(null)
|
||||
|
||||
private fun generateStrategyName(marketSlugPrefix: String): String {
|
||||
val suffix = Instant.now().atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
||||
return "加密价差策略-${marketSlugPrefix}-$suffix"
|
||||
}
|
||||
|
||||
private fun entityToDto(e: CryptoTailStrategy, lastTriggerAt: Long?): CryptoTailStrategyDto {
|
||||
val strategyId = e.id ?: 0L
|
||||
val totalPnl = triggerRepository.sumRealizedPnlByStrategyId(strategyId)
|
||||
val settledCount = triggerRepository.countResolvedByStrategyId(strategyId)
|
||||
val winCount = triggerRepository.countWinsByStrategyId(strategyId)
|
||||
val winRateStr = if (settledCount > 0L) {
|
||||
BigDecimal(winCount).divide(BigDecimal(settledCount), 4, java.math.RoundingMode.HALF_UP).toPlainString()
|
||||
} else null
|
||||
return CryptoTailStrategyDto(
|
||||
id = strategyId,
|
||||
accountId = e.accountId,
|
||||
name = e.name,
|
||||
marketSlugPrefix = e.marketSlugPrefix,
|
||||
marketTitle = null,
|
||||
intervalSeconds = e.intervalSeconds,
|
||||
windowStartSeconds = e.windowStartSeconds,
|
||||
windowEndSeconds = e.windowEndSeconds,
|
||||
minPrice = e.minPrice.toPlainString(),
|
||||
maxPrice = e.maxPrice.toPlainString(),
|
||||
amountMode = e.amountMode,
|
||||
amountValue = e.amountValue.toPlainString(),
|
||||
spreadMode = e.spreadMode.name,
|
||||
spreadValue = e.spreadValue?.toPlainString(),
|
||||
spreadDirection = e.spreadDirection.name,
|
||||
enabled = e.enabled,
|
||||
lastTriggerAt = lastTriggerAt,
|
||||
totalRealizedPnl = totalPnl?.toPlainString(),
|
||||
settledCount = settledCount,
|
||||
winCount = winCount,
|
||||
winRate = winRateStr,
|
||||
createdAt = e.createdAt,
|
||||
updatedAt = e.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
private fun triggerToDto(t: CryptoTailStrategyTrigger): CryptoTailStrategyTriggerDto = CryptoTailStrategyTriggerDto(
|
||||
id = t.id ?: 0L,
|
||||
strategyId = t.strategyId,
|
||||
periodStartUnix = t.periodStartUnix,
|
||||
marketTitle = t.marketTitle,
|
||||
outcomeIndex = t.outcomeIndex,
|
||||
triggerPrice = t.triggerPrice.toPlainString(),
|
||||
amountUsdc = t.amountUsdc.toPlainString(),
|
||||
orderId = t.orderId,
|
||||
status = t.status,
|
||||
failReason = t.failReason,
|
||||
resolved = t.resolved,
|
||||
realizedPnl = t.realizedPnl?.toPlainString(),
|
||||
winnerOutcomeIndex = t.winnerOutcomeIndex,
|
||||
settledAt = t.settledAt,
|
||||
createdAt = t.createdAt
|
||||
)
|
||||
}
|
||||
+82
@@ -15,6 +15,7 @@ import org.springframework.context.ApplicationContextAware
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.PolymarketActivityWsService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.UnifiedOnChainWsService
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -76,6 +77,17 @@ class ApiHealthCheckService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 BinanceKlineService(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
private fun getBinanceKlineService(): BinanceKlineService? {
|
||||
return try {
|
||||
applicationContext?.getBean(BinanceKlineService::class.java)
|
||||
} catch (e: BeansException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java)
|
||||
|
||||
/**
|
||||
@@ -91,6 +103,8 @@ class ApiHealthCheckService(
|
||||
async { checkDataApi() },
|
||||
async { checkGammaApi() },
|
||||
async { checkPolygonRpc() },
|
||||
async { checkBinanceApi() },
|
||||
async { checkBinanceWebSocket() },
|
||||
async { checkPolymarketRtdsWebSocket() },
|
||||
async { checkPolymarketActivityWebSocket() },
|
||||
async { checkUnifiedOnChainWebSocket() },
|
||||
@@ -197,6 +211,74 @@ class ApiHealthCheckService(
|
||||
checkJsonRpcApi("Polygon RPC", rpcUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查币安 API(用于 K 线等)
|
||||
* 使用 /api/v3/ping 端点
|
||||
*/
|
||||
private suspend fun checkBinanceApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
val url = "https://api.binance.com/api/v3/ping"
|
||||
checkApi("币安 API", url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查币安 K 线 WebSocket 连接状态(5m / 15m)
|
||||
*/
|
||||
private suspend fun checkBinanceWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
|
||||
val binanceWsUrl = "wss://stream.binance.com:9443"
|
||||
try {
|
||||
val binanceKlineService = getBinanceKlineService()
|
||||
if (binanceKlineService == null) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "error",
|
||||
message = "服务未初始化"
|
||||
)
|
||||
}
|
||||
val statuses = binanceKlineService.getConnectionStatuses()
|
||||
val total = statuses.size
|
||||
val connected = statuses.values.count { it }
|
||||
if (connected == total && total > 0) {
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "success",
|
||||
message = "连接正常 (按策略订阅)"
|
||||
)
|
||||
} else if (total == 0) {
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "success",
|
||||
message = "无加密价差策略,未订阅"
|
||||
)
|
||||
} else if (connected > 0) {
|
||||
val which = statuses.filter { it.value }.keys.joinToString("、")
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "error",
|
||||
message = "部分连接正常 ($which)"
|
||||
)
|
||||
} else {
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "error",
|
||||
message = "连接断开"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查币安 WebSocket 状态失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "error",
|
||||
message = "检查失败:${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Polymarket RTDS WebSocket 连接状态
|
||||
* 用于订单推送服务
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.dto.GeoblockCheckDto
|
||||
import com.wrbug.polymarketbot.dto.PolymarketGeoblockApiResponse
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Service
|
||||
class GeoblockService(
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(GeoblockService::class.java)
|
||||
|
||||
companion object {
|
||||
private const val GEOBLOCK_URL = "https://polymarket.com/api/geoblock"
|
||||
}
|
||||
|
||||
suspend fun checkGeoblock(): Result<GeoblockCheckDto> = withContext(Dispatchers.IO) {
|
||||
val client = createClient()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
checkGeoblockWithClient(client)
|
||||
}
|
||||
|
||||
fun checkGeoblockWithClient(client: OkHttpClient): Result<GeoblockCheckDto> {
|
||||
return try {
|
||||
val request = Request.Builder()
|
||||
.url(GEOBLOCK_URL)
|
||||
.get()
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(
|
||||
IllegalStateException("HTTP ${response.code}: ${response.message}")
|
||||
)
|
||||
}
|
||||
val body = response.body?.string()
|
||||
?: return Result.failure(IllegalStateException("Empty response body"))
|
||||
val apiResponse = gson.fromJson(body, PolymarketGeoblockApiResponse::class.java)
|
||||
Result.success(
|
||||
GeoblockCheckDto(
|
||||
blocked = apiResponse.blocked,
|
||||
ip = apiResponse.ip,
|
||||
country = apiResponse.country,
|
||||
region = apiResponse.region,
|
||||
checkedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Geoblock 检查失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.NotificationTemplate
|
||||
import com.wrbug.polymarketbot.repository.NotificationTemplateRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.annotation.Lazy
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* 消息模板服务
|
||||
* 负责管理消息模板、渲染模板、提供变量信息
|
||||
*/
|
||||
@Service
|
||||
class NotificationTemplateService(
|
||||
private val templateRepository: NotificationTemplateRepository,
|
||||
@Lazy private val telegramNotificationService: TelegramNotificationService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(NotificationTemplateService::class.java)
|
||||
|
||||
companion object {
|
||||
// 模板类型定义
|
||||
val TEMPLATE_TYPES = mapOf(
|
||||
"ORDER_SUCCESS" to TemplateTypeInfoDto(
|
||||
type = "ORDER_SUCCESS",
|
||||
name = "订单成功通知",
|
||||
description = "订单创建成功时发送的通知"
|
||||
),
|
||||
"ORDER_FAILED" to TemplateTypeInfoDto(
|
||||
type = "ORDER_FAILED",
|
||||
name = "订单失败通知",
|
||||
description = "订单创建失败时发送的通知"
|
||||
),
|
||||
"ORDER_FILTERED" to TemplateTypeInfoDto(
|
||||
type = "ORDER_FILTERED",
|
||||
name = "订单过滤通知",
|
||||
description = "订单被风控过滤时发送的通知"
|
||||
),
|
||||
"CRYPTO_TAIL_SUCCESS" to TemplateTypeInfoDto(
|
||||
type = "CRYPTO_TAIL_SUCCESS",
|
||||
name = "加密价差策略成功通知",
|
||||
description = "加密价差策略下单成功时发送的通知"
|
||||
),
|
||||
"REDEEM_SUCCESS" to TemplateTypeInfoDto(
|
||||
type = "REDEEM_SUCCESS",
|
||||
name = "仓位赎回成功通知",
|
||||
description = "仓位赎回成功时发送的通知"
|
||||
),
|
||||
"REDEEM_NO_RETURN" to TemplateTypeInfoDto(
|
||||
type = "REDEEM_NO_RETURN",
|
||||
name = "仓位结算(无收益)通知",
|
||||
description = "仓位结算但无收益时发送的通知"
|
||||
)
|
||||
)
|
||||
|
||||
// 变量分类
|
||||
val VARIABLE_CATEGORIES = listOf(
|
||||
TemplateVariableCategoryDto("common", 0),
|
||||
TemplateVariableCategoryDto("order", 10),
|
||||
TemplateVariableCategoryDto("copy_trading", 20),
|
||||
TemplateVariableCategoryDto("redeem", 30),
|
||||
TemplateVariableCategoryDto("error", 40),
|
||||
TemplateVariableCategoryDto("filter", 50),
|
||||
TemplateVariableCategoryDto("strategy", 60)
|
||||
)
|
||||
|
||||
// 各模板类型可用的变量
|
||||
val TEMPLATE_VARIABLES = mapOf(
|
||||
"ORDER_SUCCESS" to listOf(
|
||||
// 通用变量
|
||||
TemplateVariableDto("account_name", "common", 1),
|
||||
TemplateVariableDto("wallet_address", "common", 2),
|
||||
TemplateVariableDto("time", "common", 3),
|
||||
// 订单变量
|
||||
TemplateVariableDto("order_id", "order", 10),
|
||||
TemplateVariableDto("market_title", "order", 11),
|
||||
TemplateVariableDto("market_link", "order", 12),
|
||||
TemplateVariableDto("side", "order", 13),
|
||||
TemplateVariableDto("outcome", "order", 14),
|
||||
TemplateVariableDto("price", "order", 15),
|
||||
TemplateVariableDto("quantity", "order", 16),
|
||||
TemplateVariableDto("amount", "order", 17),
|
||||
TemplateVariableDto("available_balance", "order", 18),
|
||||
// 跟单变量
|
||||
TemplateVariableDto("leader_name", "copy_trading", 21),
|
||||
TemplateVariableDto("config_name", "copy_trading", 22)
|
||||
),
|
||||
"ORDER_FAILED" to listOf(
|
||||
// 通用变量
|
||||
TemplateVariableDto("account_name", "common", 1),
|
||||
TemplateVariableDto("wallet_address", "common", 2),
|
||||
TemplateVariableDto("time", "common", 3),
|
||||
// 订单变量
|
||||
TemplateVariableDto("market_title", "order", 10),
|
||||
TemplateVariableDto("market_link", "order", 11),
|
||||
TemplateVariableDto("side", "order", 12),
|
||||
TemplateVariableDto("outcome", "order", 13),
|
||||
TemplateVariableDto("price", "order", 14),
|
||||
TemplateVariableDto("quantity", "order", 15),
|
||||
TemplateVariableDto("amount", "order", 16),
|
||||
// 错误变量
|
||||
TemplateVariableDto("error_message", "error", 20)
|
||||
),
|
||||
"ORDER_FILTERED" to listOf(
|
||||
// 通用变量
|
||||
TemplateVariableDto("account_name", "common", 1),
|
||||
TemplateVariableDto("wallet_address", "common", 2),
|
||||
TemplateVariableDto("time", "common", 3),
|
||||
// 订单变量
|
||||
TemplateVariableDto("market_title", "order", 10),
|
||||
TemplateVariableDto("market_link", "order", 11),
|
||||
TemplateVariableDto("side", "order", 12),
|
||||
TemplateVariableDto("outcome", "order", 13),
|
||||
TemplateVariableDto("price", "order", 14),
|
||||
TemplateVariableDto("quantity", "order", 15),
|
||||
TemplateVariableDto("amount", "order", 16),
|
||||
// 过滤变量
|
||||
TemplateVariableDto("filter_type", "filter", 20),
|
||||
TemplateVariableDto("filter_reason", "filter", 21)
|
||||
),
|
||||
"CRYPTO_TAIL_SUCCESS" to listOf(
|
||||
// 通用变量
|
||||
TemplateVariableDto("account_name", "common", 1),
|
||||
TemplateVariableDto("wallet_address", "common", 2),
|
||||
TemplateVariableDto("time", "common", 3),
|
||||
// 订单变量
|
||||
TemplateVariableDto("order_id", "order", 10),
|
||||
TemplateVariableDto("market_title", "order", 11),
|
||||
TemplateVariableDto("market_link", "order", 12),
|
||||
TemplateVariableDto("side", "order", 13),
|
||||
TemplateVariableDto("outcome", "order", 14),
|
||||
TemplateVariableDto("price", "order", 15),
|
||||
TemplateVariableDto("quantity", "order", 16),
|
||||
TemplateVariableDto("amount", "order", 17),
|
||||
// 策略变量
|
||||
TemplateVariableDto("strategy_name", "strategy", 20)
|
||||
),
|
||||
"REDEEM_SUCCESS" to listOf(
|
||||
// 通用变量
|
||||
TemplateVariableDto("account_name", "common", 1),
|
||||
TemplateVariableDto("wallet_address", "common", 2),
|
||||
TemplateVariableDto("time", "common", 3),
|
||||
// 赎回变量
|
||||
TemplateVariableDto("transaction_hash", "redeem", 10),
|
||||
TemplateVariableDto("total_value", "redeem", 11),
|
||||
TemplateVariableDto("available_balance", "redeem", 12)
|
||||
),
|
||||
"REDEEM_NO_RETURN" to listOf(
|
||||
// 通用变量
|
||||
TemplateVariableDto("account_name", "common", 1),
|
||||
TemplateVariableDto("wallet_address", "common", 2),
|
||||
TemplateVariableDto("time", "common", 3),
|
||||
// 赎回变量
|
||||
TemplateVariableDto("transaction_hash", "redeem", 10),
|
||||
TemplateVariableDto("available_balance", "redeem", 11)
|
||||
)
|
||||
)
|
||||
|
||||
// 默认模板
|
||||
val DEFAULT_TEMPLATES = mapOf(
|
||||
"ORDER_SUCCESS" to """
|
||||
🚀 <b>订单创建成功</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 订单ID: <code>{{order_id}}</code>
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>${'$'}{{amount}}</code>
|
||||
• 账户: {{account_name}}
|
||||
• 可用余额: <code>${'$'}{{available_balance}}</code>
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent(),
|
||||
"ORDER_FAILED" to """
|
||||
❌ <b>订单创建失败</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>${'$'}{{amount}}</code>
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⚠️ <b>错误信息:</b>
|
||||
<code>{{error_message}}</code>
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent(),
|
||||
"ORDER_FILTERED" to """
|
||||
🚫 <b>订单被过滤</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>${'$'}{{amount}}</code>
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⚠️ <b>过滤类型:</b> <code>{{filter_type}}</code>
|
||||
|
||||
📝 <b>过滤原因:</b>
|
||||
<code>{{filter_reason}}</code>
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent(),
|
||||
"CRYPTO_TAIL_SUCCESS" to """
|
||||
🚀 <b>加密价差策略下单成功</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 订单ID: <code>{{order_id}}</code>
|
||||
• 策略: {{strategy_name}}
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>${'$'}{{amount}}</code>
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent(),
|
||||
"REDEEM_SUCCESS" to """
|
||||
💸 <b>仓位赎回成功</b>
|
||||
|
||||
📊 <b>赎回信息:</b>
|
||||
• 账户: {{account_name}}
|
||||
• 交易哈希: <code>{{transaction_hash}}</code>
|
||||
• 赎回总价值: <code>${'$'}{{total_value}}</code>
|
||||
• 可用余额: <code>${'$'}{{available_balance}}</code>
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent(),
|
||||
"REDEEM_NO_RETURN" to """
|
||||
📋 <b>仓位已结算(无收益)</b>
|
||||
|
||||
📊 <b>结算信息:</b>
|
||||
<i>市场已结算,您的预测未命中,赎回价值为 0。</i>
|
||||
|
||||
• 账户: {{account_name}}
|
||||
• 交易哈希: <code>{{transaction_hash}}</code>
|
||||
• 可用余额: <code>${'$'}{{available_balance}}</code>
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有模板类型
|
||||
*/
|
||||
fun getTemplateTypes(): List<TemplateTypeInfoDto> {
|
||||
return TEMPLATE_TYPES.values.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有模板列表
|
||||
*/
|
||||
fun getAllTemplates(): List<NotificationTemplateDto> {
|
||||
return templateRepository.findAll().map { it.toDto() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个模板
|
||||
*/
|
||||
fun getTemplate(templateType: String): NotificationTemplateDto? {
|
||||
return templateRepository.findByTemplateType(templateType)?.toDto()
|
||||
?: DEFAULT_TEMPLATES[templateType]?.let {
|
||||
NotificationTemplateDto(
|
||||
templateType = templateType,
|
||||
templateContent = it,
|
||||
isDefault = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板可用变量
|
||||
*/
|
||||
fun getTemplateVariables(templateType: String): TemplateVariablesResponse? {
|
||||
if (!TEMPLATE_TYPES.containsKey(templateType)) return null
|
||||
val variables = TEMPLATE_VARIABLES[templateType] ?: emptyList()
|
||||
|
||||
// 获取使用的分类
|
||||
val usedCategories = variables.map { it.category }.toSet()
|
||||
val categories = VARIABLE_CATEGORIES.filter { usedCategories.contains(it.key) }
|
||||
|
||||
return TemplateVariablesResponse(
|
||||
templateType = templateType,
|
||||
categories = categories,
|
||||
variables = variables
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模板
|
||||
*/
|
||||
@Transactional
|
||||
fun updateTemplate(templateType: String, content: String): NotificationTemplateDto {
|
||||
val template = templateRepository.findByTemplateType(templateType)
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
return if (template != null) {
|
||||
template.templateContent = content
|
||||
template.isDefault = false
|
||||
template.updatedAt = now
|
||||
templateRepository.save(template).toDto()
|
||||
} else {
|
||||
val newTemplate = NotificationTemplate(
|
||||
templateType = templateType,
|
||||
templateContent = content,
|
||||
isDefault = false,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
templateRepository.save(newTemplate).toDto()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置模板为默认
|
||||
*/
|
||||
@Transactional
|
||||
fun resetTemplate(templateType: String): NotificationTemplateDto? {
|
||||
val defaultContent = DEFAULT_TEMPLATES[templateType] ?: return null
|
||||
val template = templateRepository.findByTemplateType(templateType)
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
return if (template != null) {
|
||||
template.templateContent = defaultContent
|
||||
template.isDefault = true
|
||||
template.updatedAt = now
|
||||
templateRepository.save(template).toDto()
|
||||
} else {
|
||||
val newTemplate = NotificationTemplate(
|
||||
templateType = templateType,
|
||||
templateContent = defaultContent,
|
||||
isDefault = true,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
templateRepository.save(newTemplate).toDto()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染模板(按类型取模板内容后替换变量)
|
||||
* 优化:先解析模版中需要的变量,只替换这些变量,未提供的变量使用 "-" 占位
|
||||
*/
|
||||
fun renderTemplate(templateType: String, variables: Map<String, String>): String {
|
||||
val template = getTemplate(templateType)
|
||||
val content = template?.templateContent ?: DEFAULT_TEMPLATES[templateType] ?: ""
|
||||
return renderTemplateContent(content, variables)
|
||||
}
|
||||
|
||||
/**
|
||||
* 对给定模板内容做变量替换(不查库)
|
||||
* 优化:先解析模版中的变量占位符,只替换这些变量,未提供的变量使用 "-" 占位
|
||||
*/
|
||||
fun renderTemplateContent(content: String, variables: Map<String, String>): String {
|
||||
// 先解析模版中需要的变量
|
||||
val requiredVariables = extractTemplateVariables(content)
|
||||
|
||||
var result = content
|
||||
// 只替换模版中实际使用的变量
|
||||
requiredVariables.forEach { varName ->
|
||||
val value = variables[varName]
|
||||
result = result.replace("{{$varName}}", value ?: "-")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模版中使用的变量名
|
||||
* @return 变量名列表(去重)
|
||||
*/
|
||||
private fun extractTemplateVariables(content: String): Set<String> {
|
||||
val regex = Regex("\\{\\{([^}]+)}}")
|
||||
return regex.findAll(content)
|
||||
.map { it.groupValues[1].trim() }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模版需要的变量过滤输入变量
|
||||
* 只保留模版中实际使用的变量,避免不必要的数据获取
|
||||
*/
|
||||
fun filterVariablesForTemplate(templateType: String, variables: Map<String, String>): Map<String, String> {
|
||||
val template = getTemplate(templateType)
|
||||
val content = template?.templateContent ?: DEFAULT_TEMPLATES[templateType] ?: return emptyMap()
|
||||
val requiredVariables = extractTemplateVariables(content)
|
||||
return variables.filterKeys { it in requiredVariables }
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送测试消息
|
||||
*/
|
||||
suspend fun sendTestMessage(templateType: String, content: String? = null): Boolean {
|
||||
val templateContent = content ?: getTemplate(templateType)?.templateContent ?: return false
|
||||
val testVariables = generateTestVariables(templateType)
|
||||
val message = renderTemplateContent(templateContent, testVariables)
|
||||
return try {
|
||||
telegramNotificationService.sendMessage(message)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
logger.error("发送测试消息失败: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成测试变量数据
|
||||
*/
|
||||
private fun generateTestVariables(templateType: String): Map<String, String> {
|
||||
return mapOf(
|
||||
"account_name" to "测试账户",
|
||||
"wallet_address" to "0x1234...5678",
|
||||
"time" to "2024-01-15 12:30:00",
|
||||
"order_id" to "12345678",
|
||||
"market_title" to "测试市场标题",
|
||||
"market_link" to "https://polymarket.com/event/test",
|
||||
"side" to "买入",
|
||||
"outcome" to "YES",
|
||||
"price" to "0.55",
|
||||
"quantity" to "100",
|
||||
"amount" to "55.00",
|
||||
"available_balance" to "1000.00",
|
||||
"leader_name" to "测试Leader",
|
||||
"config_name" to "测试配置",
|
||||
"error_message" to "余额不足",
|
||||
"filter_type" to "价差过大",
|
||||
"filter_reason" to "当前市场价差为 5%,超过设定的 3% 限制",
|
||||
"strategy_name" to "BTC价差策略",
|
||||
"transaction_hash" to "0xabcd...efgh",
|
||||
"total_value" to "100.00"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity 转 DTO
|
||||
*/
|
||||
private fun NotificationTemplate.toDto() = NotificationTemplateDto(
|
||||
id = id,
|
||||
templateType = templateType,
|
||||
templateContent = templateContent,
|
||||
isDefault = isDefault,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt
|
||||
)
|
||||
}
|
||||
+102
-48
@@ -24,7 +24,8 @@ import java.util.concurrent.TimeUnit
|
||||
*/
|
||||
@Service
|
||||
class ProxyConfigService(
|
||||
private val proxyConfigRepository: ProxyConfigRepository
|
||||
private val proxyConfigRepository: ProxyConfigRepository,
|
||||
private val geoblockService: GeoblockService
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
@@ -148,100 +149,153 @@ class ProxyConfigService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理是否可用
|
||||
* 使用配置的代理请求 Polymarket 健康检查接口
|
||||
* 检查代理是否可用,并检测经该代理访问 Polymarket 时的地域限制
|
||||
*/
|
||||
fun checkProxy(): ProxyCheckResponse {
|
||||
return try {
|
||||
val config = proxyConfigRepository.findByEnabledTrue()
|
||||
?: return ProxyCheckResponse.create(
|
||||
if (config == null) {
|
||||
return ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "未配置代理或代理未启用"
|
||||
message = "未配置代理或代理未启用",
|
||||
geoblock = checkGeoblockForProxy(null)
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
if (config.type != "HTTP") {
|
||||
return ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)"
|
||||
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)",
|
||||
geoblock = checkGeoblockForProxy(null)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (config.host == null || config.port == null) {
|
||||
return ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "代理配置不完整:缺少主机或端口"
|
||||
message = "代理配置不完整:缺少主机或端口",
|
||||
geoblock = checkGeoblockForProxy(null)
|
||||
)
|
||||
}
|
||||
|
||||
// 创建代理
|
||||
val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port))
|
||||
|
||||
// 创建 OkHttpClient
|
||||
val clientBuilder = OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
|
||||
// 配置 SSL:信任所有证书(用于代理连接)
|
||||
clientBuilder.createSSLSocketFactory()
|
||||
clientBuilder.hostnameVerifier(TrustAllHostnameVerifier())
|
||||
|
||||
// 如果配置了用户名和密码,添加代理认证
|
||||
if (config.username != null && config.password != null) {
|
||||
clientBuilder.proxyAuthenticator { _, response ->
|
||||
val credential = okhttp3.Credentials.basic(config.username, config.password)
|
||||
response.request.newBuilder()
|
||||
.header("Proxy-Authorization", credential)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
val client = clientBuilder.build()
|
||||
|
||||
// 请求 Polymarket 健康检查接口
|
||||
|
||||
val client = buildProxyHttpClient(config)
|
||||
val geoblock = checkGeoblockForProxy(client)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("https://data-api.polymarket.com/")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val response = client.newCall(request).execute()
|
||||
val responseTime = System.currentTimeMillis() - startTime
|
||||
|
||||
|
||||
val responseBody = response.body?.string()
|
||||
|
||||
|
||||
if (response.isSuccessful && responseBody != null) {
|
||||
// 检查响应内容是否为 {"data": "OK"}
|
||||
if (responseBody.contains("\"data\"") && responseBody.contains("OK")) {
|
||||
logger.info("代理检查成功:host=${config.host}, port=${config.port}, responseTime=${responseTime}ms")
|
||||
val message = buildProxyCheckMessage("代理连接成功", geoblock)
|
||||
ProxyCheckResponse.create(
|
||||
success = true,
|
||||
message = "代理连接成功",
|
||||
responseTime = responseTime
|
||||
message = message,
|
||||
responseTime = responseTime,
|
||||
geoblock = geoblock
|
||||
)
|
||||
} else {
|
||||
ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "代理连接成功,但响应格式不正确:$responseBody",
|
||||
responseTime = responseTime
|
||||
message = buildProxyCheckMessage(
|
||||
"代理连接成功,但响应格式不正确:$responseBody",
|
||||
geoblock
|
||||
),
|
||||
responseTime = responseTime,
|
||||
geoblock = geoblock
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "代理连接失败:HTTP ${response.code} ${response.message}",
|
||||
responseTime = responseTime
|
||||
message = buildProxyCheckMessage(
|
||||
"代理连接失败:HTTP ${response.code} ${response.message}",
|
||||
geoblock
|
||||
),
|
||||
responseTime = responseTime,
|
||||
geoblock = geoblock
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("代理检查异常", e)
|
||||
ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "代理检查失败:${e.message}"
|
||||
message = "代理检查失败:${e.message}",
|
||||
geoblock = checkGeoblockForProxy(null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildProxyHttpClient(config: ProxyConfig): OkHttpClient {
|
||||
val host = requireNotNull(config.host) { "代理主机不能为空" }
|
||||
val port = requireNotNull(config.port) { "代理端口不能为空" }
|
||||
val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port))
|
||||
val clientBuilder = OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
|
||||
clientBuilder.createSSLSocketFactory()
|
||||
clientBuilder.hostnameVerifier(TrustAllHostnameVerifier())
|
||||
|
||||
if (config.username != null && config.password != null) {
|
||||
clientBuilder.proxyAuthenticator { _, response ->
|
||||
val credential = Credentials.basic(config.username, config.password)
|
||||
response.request.newBuilder()
|
||||
.header("Proxy-Authorization", credential)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
return clientBuilder.build()
|
||||
}
|
||||
|
||||
private fun checkGeoblockForProxy(client: OkHttpClient?): ProxyCheckGeoblockResult {
|
||||
val httpClient = client ?: com.wrbug.polymarketbot.util.createClient()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
return geoblockService.checkGeoblockWithClient(httpClient).fold(
|
||||
onSuccess = { dto ->
|
||||
ProxyCheckGeoblockResult(
|
||||
checked = true,
|
||||
blocked = dto.blocked,
|
||||
ip = dto.ip,
|
||||
country = dto.country,
|
||||
region = dto.region,
|
||||
message = if (dto.blocked) {
|
||||
"当前出口 IP(${dto.country}/${dto.region})在 Polymarket 受限,无法下单"
|
||||
} else {
|
||||
"当前出口 IP(${dto.country}/${dto.region})可向 Polymarket 下单"
|
||||
}
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
ProxyCheckGeoblockResult(
|
||||
checked = true,
|
||||
message = "地域限制检测失败:${e.message}"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildProxyCheckMessage(baseMessage: String, geoblock: ProxyCheckGeoblockResult): String {
|
||||
if (!geoblock.checked || geoblock.message.isNullOrBlank()) {
|
||||
return baseMessage
|
||||
}
|
||||
return "$baseMessage;${geoblock.message}"
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除代理配置
|
||||
|
||||
+630
-30
@@ -4,12 +4,17 @@ import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.util.Eip712Encoder
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import kotlinx.coroutines.delay
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Response
|
||||
import java.math.BigInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* RelayClient 服务
|
||||
@@ -19,8 +24,8 @@ import java.math.BigInteger
|
||||
* 如果需要真正的 Gasless 功能,需要集成 Builder Relayer API
|
||||
*
|
||||
* 参考:
|
||||
* - TypeScript: @polymarket/builder-relayer-client
|
||||
* - TypeScript: utils/redeem.ts
|
||||
* - TypeScript: https://github.com/Polymarket/builder-relayer-client(client.execute、src/encode/safe.ts MultiSend)
|
||||
* - 赎回 calldata 由本服务构建,官方仓库无 redeem 工具;Neg Risk 逻辑见 docs/neg-risk-redeem.md
|
||||
*/
|
||||
@Service
|
||||
class RelayClientService(
|
||||
@@ -34,17 +39,97 @@ class RelayClientService(
|
||||
// ConditionalTokens 合约地址
|
||||
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||
|
||||
// USDC.e 合约地址
|
||||
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
// pUSD 合约地址(普通市场抵押品)
|
||||
private val usdcContractAddress = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"
|
||||
|
||||
// USDC.e 合约地址(仅用于 wrap 到 pUSD)
|
||||
private val usdceContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
|
||||
// CollateralOnramp 合约地址(USDC.e → pUSD)
|
||||
private val collateralOnrampAddress = "0x93070a847efEf7F70739046A929D47a521F5B8ee"
|
||||
|
||||
// Neg Risk 市场使用的 WrappedCollateral 合约地址(Polygon,neg-risk-ctf-adapter)
|
||||
private val negRiskWrappedCollateralAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
|
||||
|
||||
// 空集合ID
|
||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
// Polygon PROXY(Magic)合约地址,参考 builder-relayer-client config
|
||||
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
|
||||
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
|
||||
// PROXY relayCall 内层 gasLimit(签名参数)不能给过大值,否则 RelayHub 会因 gasleft 校验失败回滚。
|
||||
private val defaultProxyGasLimit = "2400000"
|
||||
private val maxProxyGasLimit = BigInteger.valueOf(2400000)
|
||||
|
||||
// Safe MultiSend 合约地址(Polygon 主网)
|
||||
private val safeMultisendAddress = "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761"
|
||||
|
||||
// Builder Relayer API 交易类型常量
|
||||
private val RELAYER_TYPE_PROXY = "PROXY"
|
||||
private val RELAYER_TYPE_SAFE = "SAFE"
|
||||
private val RELAYER_TYPE_SAFE_CREATE = "SAFE-CREATE"
|
||||
|
||||
// Safe 代理工厂(用于 SAFE-CREATE 部署)
|
||||
private val safeProxyFactoryAddress = PolymarketConstants.SAFE_PROXY_FACTORY_ADDRESS
|
||||
|
||||
private val polygonRpcApi: EthereumRpcApi by lazy {
|
||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||
retrofitFactory.createEthereumRpcApi(rpcUrl)
|
||||
}
|
||||
|
||||
/** 遇到 429 限流时的重试次数 */
|
||||
private val builderRelayerRateLimitMaxAttempts = 3
|
||||
|
||||
/** 429 限流重试退避基数(毫秒),第 n 次重试等待 baseMs * 2^(n-1) */
|
||||
private val builderRelayerRateLimitBackoffMs = 2000L
|
||||
|
||||
/** Builder Relayer 配额用尽后的冷却截止时间(毫秒时间戳),在此时间前不再发起赎回 */
|
||||
private val builderRelayerQuotaBlockedUntilMs = AtomicLong(0)
|
||||
|
||||
/**
|
||||
* 是否处于 Builder Relayer 配额冷却期(配额用尽后在该时间内不再发起赎回)。
|
||||
*/
|
||||
fun isBuilderRelayerQuotaBlocked(): Boolean = System.currentTimeMillis() < builderRelayerQuotaBlockedUntilMs.get()
|
||||
|
||||
/**
|
||||
* 配额冷却剩余秒数,未在冷却期时返回 0。
|
||||
*/
|
||||
fun getBuilderRelayerQuotaBlockedRemainingSeconds(): Long {
|
||||
val remaining = (builderRelayerQuotaBlockedUntilMs.get() - System.currentTimeMillis()) / 1000
|
||||
return maxOf(0, remaining)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 API 错误响应中解析 "quota exceeded... resets in N seconds",并设置配额冷却截止时间。
|
||||
*/
|
||||
private fun updateQuotaBlockedFromErrorBody(errorBody: String) {
|
||||
if (!errorBody.contains("quota exceeded", ignoreCase = true)) return
|
||||
val regex = Regex("resets\\s+in\\s+(\\d+)\\s+seconds", RegexOption.IGNORE_CASE)
|
||||
regex.find(errorBody)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { seconds ->
|
||||
val untilMs = System.currentTimeMillis() + seconds * 1000
|
||||
builderRelayerQuotaBlockedUntilMs.set(untilMs)
|
||||
logger.warn("Builder Relayer 配额已用尽,${seconds}秒内不再发起赎回")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 Builder Relayer API 调用进行 429 限流重试(指数退避)。
|
||||
* 当 HTTP 状态为 429(Too Many Requests,如 Cloudflare 1015)时等待后重试,避免瞬时限流导致赎回失败。
|
||||
*/
|
||||
private suspend fun <T> withBuilderRelayerRateLimitRetry(block: suspend () -> Response<T>): Response<T> {
|
||||
var lastResponse: Response<T>? = null
|
||||
for (attempt in 1..builderRelayerRateLimitMaxAttempts) {
|
||||
val response = block()
|
||||
lastResponse = response
|
||||
if (response.code() != 429) return response
|
||||
if (attempt == builderRelayerRateLimitMaxAttempts) return response
|
||||
val delayMs = builderRelayerRateLimitBackoffMs * (1L shl (attempt - 1))
|
||||
logger.warn("Builder Relayer API 限流(429),${delayMs}ms 后重试 (${attempt}/${builderRelayerRateLimitMaxAttempts})")
|
||||
delay(delayMs)
|
||||
}
|
||||
return lastResponse!!
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Builder Relayer API 客户端(动态获取,因为配置可能更新)
|
||||
*/
|
||||
@@ -115,6 +200,7 @@ class RelayClientService(
|
||||
Result.success(responseTime)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -160,19 +246,22 @@ class RelayClientService(
|
||||
/**
|
||||
* 创建赎回交易(支持多个 indexSets,用于批量赎回)
|
||||
* 参考 TypeScript: utils/redeem.ts 的 createRedeemTx
|
||||
* Neg Risk 市场使用 WrappedCollateral 作为抵押品,需传 isNegRisk=true
|
||||
*
|
||||
* @param conditionId 市场条件ID
|
||||
* @param indexSets 索引集合列表(每个元素是 2^outcomeIndex)
|
||||
* @param isNegRisk 是否为 Neg Risk 市场(true 时使用 WrappedCollateral 地址)
|
||||
* @return Safe 交易对象
|
||||
*/
|
||||
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>): SafeTransaction {
|
||||
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>, isNegRisk: Boolean = false): SafeTransaction {
|
||||
// 编码 redeemPositions 函数调用
|
||||
val functionSelector = EthereumUtils.getFunctionSelector(
|
||||
"redeemPositions(address,bytes32,bytes32,uint256[])"
|
||||
)
|
||||
|
||||
// 编码参数
|
||||
val encodedCollateral = EthereumUtils.encodeAddress(usdcContractAddress)
|
||||
// Neg Risk 市场仓位由 WrappedCollateral 抵押,普通市场由 USDC 抵押
|
||||
val collateralAddress = if (isNegRisk) negRiskWrappedCollateralAddress else usdcContractAddress
|
||||
val encodedCollateral = EthereumUtils.encodeAddress(collateralAddress)
|
||||
val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET)
|
||||
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
|
||||
|
||||
@@ -201,33 +290,189 @@ class RelayClientService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Safe 交易(通过 Proxy.execTransaction)
|
||||
* 参考 TypeScript: RelayClient.execute()
|
||||
* 创建 WCOL 解包交易(将 Wrapped Collateral 执行解包)
|
||||
* 合约: Neg Risk WrappedCollateral 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2
|
||||
* 方法: unwrap(address _to, uint256 _amount)
|
||||
*
|
||||
* 优先使用 Builder Relayer(Gasless),如果未配置则回退到手动发送交易
|
||||
* Safe 与 Magic 共用此交易对象:Safe 走 [executeViaBuilderRelayer] / [executeManually](execTransaction),
|
||||
* Magic 走 [executeViaBuilderRelayerProxy](encodeProxyTransactionData),语义一致。
|
||||
*
|
||||
* @param toAddress 接收解包资产的地址(通常为 proxy 自身,使余额留在代理钱包)
|
||||
* @param amountWei WCOL 数量(6 位小数对应的 raw 值,与 balanceOf 返回一致)
|
||||
* @return Safe 交易对象
|
||||
*/
|
||||
fun createUnwrapWcolTx(toAddress: String, amountWei: BigInteger): SafeTransaction {
|
||||
val functionSelector = EthereumUtils.getFunctionSelector("unwrap(address,uint256)")
|
||||
val encodedTo = EthereumUtils.encodeAddress(toAddress)
|
||||
val encodedAmount = EthereumUtils.encodeUint256(amountWei)
|
||||
val callData = "0x" + functionSelector.removePrefix("0x") + encodedTo + encodedAmount
|
||||
return SafeTransaction(
|
||||
to = negRiskWrappedCollateralAddress,
|
||||
operation = 0, // CALL
|
||||
data = callData,
|
||||
value = "0"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 USDC approve 交易(ERC20 approve(spender, amount))
|
||||
* 用于 Polymarket 设置步骤3:代币授权
|
||||
*/
|
||||
fun createUsdcApproveTx(spender: String, amount: BigInteger): SafeTransaction {
|
||||
val functionSelector = EthereumUtils.getFunctionSelector("approve(address,uint256)")
|
||||
val encodedSpender = EthereumUtils.encodeAddress(spender)
|
||||
val encodedAmount = EthereumUtils.encodeUint256(amount)
|
||||
val callData = "0x" + functionSelector.removePrefix("0x") + encodedSpender + encodedAmount
|
||||
return SafeTransaction(
|
||||
to = usdcContractAddress,
|
||||
operation = 0, // CALL
|
||||
data = callData,
|
||||
value = "0"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 USDC.e approve 交易(用于 wrap 到 pUSD)
|
||||
* 授权 CollateralOnramp 合约花费用户的 USDC.e
|
||||
*/
|
||||
fun createUsdceApproveForWrapTx(amount: BigInteger): SafeTransaction {
|
||||
val functionSelector = EthereumUtils.getFunctionSelector("approve(address,uint256)")
|
||||
val encodedSpender = EthereumUtils.encodeAddress(collateralOnrampAddress)
|
||||
val encodedAmount = EthereumUtils.encodeUint256(amount)
|
||||
val callData = "0x" + functionSelector.removePrefix("0x") + encodedSpender + encodedAmount
|
||||
return SafeTransaction(
|
||||
to = usdceContractAddress,
|
||||
operation = 0,
|
||||
data = callData,
|
||||
value = "0"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 USDC.e → pUSD wrap 交易
|
||||
* CollateralOnramp.wrap(address _asset, address _to, uint256 _amount)
|
||||
*/
|
||||
fun createWrapToPusdTx(recipientAddress: String, amount: BigInteger): SafeTransaction {
|
||||
val functionSelector = EthereumUtils.getFunctionSelector("wrap(address,address,uint256)")
|
||||
val asset = EthereumUtils.encodeAddress(usdceContractAddress)
|
||||
val to = EthereumUtils.encodeAddress(recipientAddress)
|
||||
val amt = EthereumUtils.encodeUint256(amount)
|
||||
val callData = "0x" + functionSelector.removePrefix("0x") + asset + to + amt
|
||||
return SafeTransaction(
|
||||
to = collateralOnrampAddress,
|
||||
operation = 0,
|
||||
data = callData,
|
||||
value = "0"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 MultiSend 交易(合并多个 SafeTransaction 为一笔交易)
|
||||
* 参考 TypeScript: builder-relayer-client/src/encode/safe.ts createSafeMultisendTransaction
|
||||
*
|
||||
* 使用 Gnosis Safe 的 MultiSend 合约将多个交易合并为一笔 DelegateCall 交易
|
||||
*
|
||||
* @param safeTxs 多个 Safe 交易
|
||||
* @return 合并后的 MultiSend 交易(operation = 1 = DelegateCall)
|
||||
*/
|
||||
fun createMultiSendTx(safeTxs: List<SafeTransaction>): SafeTransaction {
|
||||
if (safeTxs.isEmpty()) {
|
||||
throw IllegalArgumentException("safeTxs 不能为空")
|
||||
}
|
||||
|
||||
// 单个交易直接返回,不需要 MultiSend
|
||||
if (safeTxs.size == 1) {
|
||||
logger.debug("单个交易,不使用 MultiSend")
|
||||
return safeTxs.first()
|
||||
}
|
||||
|
||||
logger.debug("创建 MultiSend 交易: ${safeTxs.size} 个交易待合并")
|
||||
|
||||
// MultiSend 函数选择器:multiSend(bytes)
|
||||
val multiSendSelector = EthereumUtils.getFunctionSelector("multiSend(bytes)")
|
||||
|
||||
// 编码每个交易:encodePacked([uint8 operation, address to, uint256 value, uint256 dataLength, bytes data])
|
||||
// 与 builder-relayer-client encode/safe.ts 完全一致
|
||||
val encodedTransactions = safeTxs.map { tx ->
|
||||
val operation = tx.operation.toByte()
|
||||
// address: 20 字节,右对齐(取最后 40 个十六进制字符)
|
||||
val toHex = tx.to.removePrefix("0x").lowercase().padStart(40, '0').takeLast(40)
|
||||
val to = EthereumUtils.hexToBytes(toHex)
|
||||
// value: 32 字节大端
|
||||
val valueHex = BigInteger(tx.value).toString(16).padStart(64, '0')
|
||||
val value = EthereumUtils.hexToBytes(valueHex)
|
||||
|
||||
val dataBytes = EthereumUtils.hexToBytes(tx.data.removePrefix("0x"))
|
||||
// dataLength: 32 字节大端,表示 data 的字节数
|
||||
val dataLengthHex = BigInteger.valueOf(dataBytes.size.toLong()).toString(16).padStart(64, '0')
|
||||
val dataLength = EthereumUtils.hexToBytes(dataLengthHex)
|
||||
|
||||
// encodePacked: operation(1) + to(20) + value(32) + dataLength(32) + data(variable)
|
||||
byteArrayOf(operation) + to + value + dataLength + dataBytes
|
||||
}
|
||||
|
||||
// 拼接所有交易(无 padding,与 viem concatHex 一致)
|
||||
val concatenatedTransactions = encodedTransactions.reduce { acc, bytes -> acc + bytes }
|
||||
val totalDataLength = concatenatedTransactions.size
|
||||
|
||||
// multiSend(bytes) 的 ABI 编码:offset(32) + length(32) + data(按 32 字节对齐 padding)
|
||||
val paddedLength = ((totalDataLength + 31) / 32) * 32
|
||||
val paddedData = concatenatedTransactions + ByteArray(paddedLength - totalDataLength)
|
||||
|
||||
val encodedOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
|
||||
val encodedLength = EthereumUtils.encodeUint256(BigInteger.valueOf(totalDataLength.toLong()))
|
||||
val encodedData = paddedData.joinToString("") { "%02x".format(it) }
|
||||
|
||||
val callData = "0x" + multiSendSelector.removePrefix("0x") + encodedOffset + encodedLength + encodedData
|
||||
|
||||
return SafeTransaction(
|
||||
to = safeMultisendAddress,
|
||||
operation = 1, // DelegateCall
|
||||
data = callData,
|
||||
value = "0"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行代理交易(Safe 或 Magic PROXY)
|
||||
* 参考 TypeScript: RelayClient.execute()
|
||||
*
|
||||
* @param privateKey 私钥
|
||||
* @param proxyAddress 代理钱包地址
|
||||
* @param safeTx Safe 交易对象
|
||||
* @param safeTx 交易对象(to/data/value)
|
||||
* @param walletType 钱包类型:MAGIC 使用 PROXY Gasless,SAFE 使用 Safe 流程
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun execute(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
safeTx: SafeTransaction
|
||||
safeTx: SafeTransaction,
|
||||
walletType: WalletType = WalletType.SAFE
|
||||
): Result<String> {
|
||||
return try {
|
||||
// 验证参数
|
||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||
}
|
||||
|
||||
// 检查 Builder API Key 是否已配置
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
|
||||
// 优先使用 Builder Relayer(Gasless)
|
||||
if (walletType == WalletType.MAGIC) {
|
||||
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
return Result.failure(IllegalStateException("Magic 账户赎回必须配置 Builder API Key(Gasless)"))
|
||||
}
|
||||
logger.info("使用 Builder Relayer PROXY 执行 Magic 赎回")
|
||||
return executeViaBuilderRelayerProxy(
|
||||
privateKey,
|
||||
proxyAddress,
|
||||
safeTx,
|
||||
builderApiKey!!,
|
||||
builderSecret!!,
|
||||
builderPassphrase!!
|
||||
)
|
||||
}
|
||||
|
||||
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
logger.info("使用 Builder Relayer 执行 Gasless 交易")
|
||||
return executeViaBuilderRelayer(
|
||||
@@ -240,15 +485,251 @@ class RelayClientService(
|
||||
)
|
||||
}
|
||||
|
||||
// 回退到手动发送交易(需要用户支付 gas)
|
||||
logger.info("Builder Relayer 未配置,使用手动发送交易(需要用户支付 gas)")
|
||||
return executeManually(privateKey, proxyAddress, safeTx)
|
||||
} catch (e: Exception) {
|
||||
logger.error("执行 Safe 交易失败: ${e.message}", e)
|
||||
logger.error("执行交易失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Builder Relayer 执行 PROXY(Magic)交易(Gasless)
|
||||
* 参考: builder-relayer-client client.ts executeProxyTransactions, builder/proxy.ts
|
||||
*/
|
||||
private suspend fun executeViaBuilderRelayerProxy(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
safeTx: SafeTransaction,
|
||||
builderApiKey: String,
|
||||
builderSecret: String,
|
||||
builderPassphrase: String
|
||||
): Result<String> {
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey,
|
||||
secret = builderSecret,
|
||||
passphrase = builderPassphrase
|
||||
)
|
||||
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
|
||||
val fromAddress = credentials.address
|
||||
|
||||
val relayPayloadResponse = withBuilderRelayerRateLimitRetry { relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY) }
|
||||
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
|
||||
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
|
||||
}
|
||||
val relayPayload = relayPayloadResponse.body()!!
|
||||
val relayAddress = relayPayload.address
|
||||
val nonce = relayPayload.nonce
|
||||
|
||||
val proxyCallData = encodeProxyTransactionData(safeTx)
|
||||
|
||||
// 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit)
|
||||
val gasLimit = try {
|
||||
val estimatedGasLimit = estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData)
|
||||
val estimatedBigInt = BigInteger(estimatedGasLimit)
|
||||
if (estimatedBigInt > maxProxyGasLimit) {
|
||||
logger.warn(
|
||||
"估算 PROXY gas limit 过大,进行截断: estimated=$estimatedGasLimit, capped=$maxProxyGasLimit"
|
||||
)
|
||||
maxProxyGasLimit.toString()
|
||||
} else {
|
||||
estimatedGasLimit
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("估算 PROXY gas limit 失败,使用默认值: ${e.message}", e)
|
||||
defaultProxyGasLimit
|
||||
}
|
||||
|
||||
val structHash = createProxyStructHash(
|
||||
from = fromAddress,
|
||||
to = proxyFactoryAddress,
|
||||
data = proxyCallData,
|
||||
txFee = "0",
|
||||
gasPrice = "0",
|
||||
gasLimit = gasLimit,
|
||||
nonce = nonce,
|
||||
relayHubAddress = relayHubAddress,
|
||||
relayAddress = relayAddress
|
||||
)
|
||||
|
||||
val prefix = "\u0019Ethereum Signed Message:\n32".toByteArray(Charsets.UTF_8)
|
||||
val messageWithPrefix = ByteArray(prefix.size + structHash.size)
|
||||
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
||||
System.arraycopy(structHash, 0, messageWithPrefix, prefix.size, structHash.size)
|
||||
|
||||
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
||||
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||
keccak256.doFinal(hashWithPrefix, 0)
|
||||
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val signature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||
val sigHex = "0x" + org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') +
|
||||
org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') +
|
||||
String.format("%02x", (signature.v as ByteArray).getOrElse(0) { 0 }.toInt() and 0xff)
|
||||
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
type = RELAYER_TYPE_PROXY,
|
||||
from = fromAddress,
|
||||
to = proxyFactoryAddress,
|
||||
proxyWallet = proxyAddress,
|
||||
data = proxyCallData,
|
||||
nonce = nonce,
|
||||
signature = sigHex,
|
||||
signatureParams = BuilderRelayerApi.SignatureParams(
|
||||
gasPrice = "0",
|
||||
gasLimit = gasLimit,
|
||||
relayerFee = "0",
|
||||
relayHub = relayHubAddress,
|
||||
relay = relayAddress
|
||||
),
|
||||
metadata = "Redeem positions via Builder Relayer PROXY"
|
||||
)
|
||||
|
||||
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
|
||||
}
|
||||
|
||||
val relayerResponse = response.body()!!
|
||||
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
|
||||
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
|
||||
logger.info("Builder Relayer PROXY 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
|
||||
return Result.success(txHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 ProxyFactory.proxy(calls) 调用数据
|
||||
* 参考: builder-relayer-client encode/proxy.ts, abis proxyFactory proxy((uint8,address,uint256,bytes)[])
|
||||
*
|
||||
* ABI 编码规则:当 tuple 数组中的 tuple 包含动态类型(bytes)时,需要先存储 tuple offset
|
||||
* 结构:
|
||||
* - selector (4 bytes)
|
||||
* - array offset (32 bytes) = 32
|
||||
* - array length (32 bytes) = 1
|
||||
* - tuple[0] offset (32 bytes) = 32 (指向 tuple 数据开始,从 array length 之后计算)
|
||||
* - tuple[0] 数据:
|
||||
* - typeCode (32 bytes) = 1
|
||||
* - to (32 bytes)
|
||||
* - value (32 bytes) = 0
|
||||
* - data offset (32 bytes) = 128 (从 tuple 数据开始计算)
|
||||
* - data length (32 bytes)
|
||||
* - data (padded to 32-byte boundary)
|
||||
*/
|
||||
private fun encodeProxyTransactionData(safeTx: SafeTransaction): String {
|
||||
val selector = EthereumUtils.getFunctionSelector("proxy((uint8,address,uint256,bytes)[])")
|
||||
val callData = safeTx.data.removePrefix("0x")
|
||||
val dataLen = callData.length / 2
|
||||
val dataLenPadded = (dataLen + 31) / 32 * 32 * 2
|
||||
val dataPadded = callData.padEnd(dataLenPadded, '0')
|
||||
|
||||
// ABI 编码:tuple 数组,tuple 包含动态类型 bytes
|
||||
// 1. array offset: 32 (指向 array length)
|
||||
val arrayOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
|
||||
// 2. array length: 1
|
||||
val arrayLength = EthereumUtils.encodeUint256(BigInteger.ONE)
|
||||
// 3. tuple[0] offset: 32 (指向 tuple 数据开始,从 array length 之后计算)
|
||||
val tupleOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
|
||||
// 4. tuple[0] 数据:
|
||||
// - typeCode: 1
|
||||
val typeCode = EthereumUtils.encodeUint256(BigInteger.ONE)
|
||||
// - to: address
|
||||
val toEncoded = EthereumUtils.encodeAddress(safeTx.to)
|
||||
// - value: 0
|
||||
val valueEncoded = EthereumUtils.encodeUint256(BigInteger.ZERO)
|
||||
// - data offset: 128 (从 tuple 数据开始计算,typeCode+to+value = 3*32 = 96,加上 offset 字段 = 128)
|
||||
val dataOffsetInTuple = BigInteger.valueOf(128)
|
||||
val dataOffsetEncoded = EthereumUtils.encodeUint256(dataOffsetInTuple)
|
||||
// - data length
|
||||
val dataLengthEncoded = EthereumUtils.encodeUint256(BigInteger.valueOf(dataLen.toLong()))
|
||||
// - data (padded)
|
||||
|
||||
return "0x" + selector.removePrefix("0x") + arrayOffset + arrayLength +
|
||||
tupleOffset + typeCode + toEncoded + valueEncoded + dataOffsetEncoded +
|
||||
dataLengthEncoded + dataPadded
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算 PROXY 交易的 gas limit
|
||||
* 参考: builder-relayer-client builder/proxy.ts getGasLimit
|
||||
*/
|
||||
private suspend fun estimateProxyGasLimit(
|
||||
from: String,
|
||||
to: String,
|
||||
data: String
|
||||
): String {
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_estimateGas",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"from" to from,
|
||||
"to" to to,
|
||||
"data" to data
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw Exception("eth_estimateGas 调用失败: ${response.code()} ${response.message()}")
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
if (rpcResponse.error != null) {
|
||||
throw Exception("eth_estimateGas 返回错误: ${rpcResponse.error.message}")
|
||||
}
|
||||
|
||||
val hexGasLimit = rpcResponse.result?.asString
|
||||
?: throw Exception("eth_estimateGas 结果为空")
|
||||
|
||||
// 将十六进制转换为十进制字符串
|
||||
val gasLimitBigInt = BigInteger(hexGasLimit.removePrefix("0x"), 16)
|
||||
return gasLimitBigInt.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 PROXY 结构哈希,参考 builder-relayer-client builder/proxy.ts createStructHash
|
||||
* concat: "rlx:" + from + to + data + txFee + gasPrice + gasLimit + nonce + relayHub + relay, then keccak256
|
||||
*/
|
||||
private fun createProxyStructHash(
|
||||
from: String,
|
||||
to: String,
|
||||
data: String,
|
||||
txFee: String,
|
||||
gasPrice: String,
|
||||
gasLimit: String,
|
||||
nonce: String,
|
||||
relayHubAddress: String,
|
||||
relayAddress: String
|
||||
): ByteArray {
|
||||
val rlxPrefix = "rlx:".toByteArray(Charsets.UTF_8)
|
||||
val fromBytes = EthereumUtils.hexToBytes(from.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
val toBytes = EthereumUtils.hexToBytes(to.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
val dataBytes = EthereumUtils.hexToBytes(data.removePrefix("0x"))
|
||||
val txFeeBytes = EthereumUtils.encodeUint256(BigInteger(txFee)).let { EthereumUtils.hexToBytes(it) }
|
||||
val gasPriceBytes = EthereumUtils.encodeUint256(BigInteger(gasPrice)).let { EthereumUtils.hexToBytes(it) }
|
||||
val gasLimitBytes = EthereumUtils.encodeUint256(BigInteger(gasLimit)).let { EthereumUtils.hexToBytes(it) }
|
||||
val nonceBytes = EthereumUtils.encodeUint256(BigInteger(nonce)).let { EthereumUtils.hexToBytes(it) }
|
||||
val relayHubBytes = EthereumUtils.hexToBytes(relayHubAddress.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
val relayBytes = EthereumUtils.hexToBytes(relayAddress.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
|
||||
val concat = rlxPrefix + fromBytes + toBytes + dataBytes + txFeeBytes + gasPriceBytes +
|
||||
gasLimitBytes + nonceBytes + relayHubBytes + relayBytes
|
||||
return EthereumUtils.keccak256(concat)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Builder Relayer 执行交易(Gasless)
|
||||
* 参考: builder-relayer-client/src/client.ts 的 execute 方法
|
||||
@@ -277,15 +758,27 @@ class RelayClientService(
|
||||
// safeTx.data 已经是带 0x 前缀的完整调用数据
|
||||
val redeemCallData = safeTx.data
|
||||
|
||||
// 获取 Proxy 的 nonce(通过 Builder Relayer API)
|
||||
val nonceResponse = relayerApi.getNonce(fromAddress, "SAFE")
|
||||
// 获取 Proxy 的 nonce(通过 Builder Relayer API,遇 429 限流时重试)
|
||||
val nonceResponse = withBuilderRelayerRateLimitRetry { relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE) }
|
||||
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
|
||||
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("获取 nonce 失败: ${nonceResponse.code()} - $errorBody"))
|
||||
}
|
||||
val proxyNonce = BigInteger(nonceResponse.body()!!.nonce)
|
||||
|
||||
// 调试 GS026:记录 nonce 与交易参数,便于与 relayer/链上对比
|
||||
logger.debug(
|
||||
"Safe exec 签名参数: nonce={}, to={}, value={}, dataLen={}, operation={}, proxyWallet={}",
|
||||
proxyNonce,
|
||||
safeTx.to,
|
||||
safeTx.value,
|
||||
redeemCallData.removePrefix("0x").length / 2,
|
||||
safeTx.operation,
|
||||
proxyAddress
|
||||
)
|
||||
|
||||
// 构建 Safe 交易哈希并签名
|
||||
// 注意:encodeSafeTx 需要 data 带 0x 前缀
|
||||
val safeTxGas = BigInteger.ZERO
|
||||
@@ -317,6 +810,12 @@ class RelayClientService(
|
||||
messageHash = safeTxHash
|
||||
)
|
||||
|
||||
// 调试 GS026:记录 EIP-712 structHash 与最终签名的 hash(可与 Safe.getTransactionHash 对比)
|
||||
logger.debug(
|
||||
"Safe exec 哈希: structHash=0x{}, hashToSign 将基于 prefix+structHash 的 keccak256",
|
||||
safeTxStructuredHash.joinToString("") { "%02x".format(it) }
|
||||
)
|
||||
|
||||
// 注意:ethers.js 的 signMessage 会添加 EIP-191 前缀
|
||||
// 格式:\x19Ethereum Signed Message:\n<length><message>
|
||||
// 我们需要模拟这个行为以匹配 TypeScript 实现
|
||||
@@ -331,21 +830,21 @@ class RelayClientService(
|
||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||
keccak256.doFinal(hashWithPrefix, 0)
|
||||
|
||||
logger.debug(
|
||||
"Safe exec hashToSign=0x{} (personal_sign 后签名的 32 字节)",
|
||||
hashWithPrefix.joinToString("") { "%02x".format(it) }
|
||||
)
|
||||
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||
|
||||
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||
val packedSignature = splitAndPackSig(safeSignature)
|
||||
|
||||
// 调试日志(地址已遮蔽)
|
||||
logger.debug("=== Builder Relayer 签名调试 ===")
|
||||
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
|
||||
logger.debug("Signature Length: ${packedSignature.length}")
|
||||
|
||||
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts)
|
||||
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
type = "SAFE",
|
||||
type = RELAYER_TYPE_SAFE,
|
||||
from = fromAddress,
|
||||
to = safeTx.to,
|
||||
proxyWallet = proxyAddress,
|
||||
@@ -360,16 +859,19 @@ class RelayClientService(
|
||||
gasToken = gasToken,
|
||||
refundReceiver = refundReceiver
|
||||
),
|
||||
metadata = "Redeem positions via Builder Relayer"
|
||||
metadata = if (safeTx.operation == 1) {
|
||||
"MultiSend redeem positions via Builder Relayer"
|
||||
} else {
|
||||
"Redeem positions via Builder Relayer"
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
|
||||
|
||||
// 调用 Builder Relayer API(认证头通过拦截器添加)
|
||||
val response = relayerApi.submitTransaction(request)
|
||||
// 调用 Builder Relayer API(认证头通过拦截器添加,遇 429 限流时重试)
|
||||
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
logger.error("Builder Relayer API 调用失败: code=${response.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
|
||||
}
|
||||
@@ -382,6 +884,104 @@ class RelayClientService(
|
||||
return Result.success(txHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Builder Relayer 部署 Safe 代理(SAFE-CREATE)
|
||||
* 参考: builder-relayer-client client.ts deploy()、builder/create.ts buildSafeCreateTransactionRequest
|
||||
*
|
||||
* @param privateKey EOA 私钥
|
||||
* @param proxyAddress 待部署的 Safe 代理地址(与 getProxyAddress 一致)
|
||||
* @param fromAddress EOA 地址(from)
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun deploySafeViaBuilderRelayer(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
fromAddress: String
|
||||
): Result<String> {
|
||||
return try {
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
return Result.failure(IllegalStateException("Builder API Key 未配置,无法执行 Safe 部署"))
|
||||
}
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey!!,
|
||||
secret = builderSecret!!,
|
||||
passphrase = builderPassphrase!!
|
||||
)
|
||||
val zeroAddress = "0x0000000000000000000000000000000000000000"
|
||||
val paymentToken = zeroAddress
|
||||
val payment = "0"
|
||||
val paymentReceiver = zeroAddress
|
||||
val domainSeparator = Eip712Encoder.encodeSafeCreateDomain(
|
||||
name = PolymarketConstants.SAFE_FACTORY_EIP712_NAME,
|
||||
chainId = 137L,
|
||||
verifyingContract = safeProxyFactoryAddress
|
||||
)
|
||||
val createProxyHash = Eip712Encoder.encodeCreateProxyMessage(
|
||||
paymentToken = paymentToken,
|
||||
payment = BigInteger.ZERO,
|
||||
paymentReceiver = paymentReceiver
|
||||
)
|
||||
val digest = Eip712Encoder.hashStructuredData(domainSeparator, createProxyHash)
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val signature = org.web3j.crypto.Sign.signMessage(digest, ecKeyPair, false)
|
||||
// SAFE-CREATE 使用标准 EIP-712 签名格式(0x + r + s + v,v 为 27/28),与 signTypedData 一致
|
||||
val signatureHex = signatureToStandardHex(signature)
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
type = RELAYER_TYPE_SAFE_CREATE,
|
||||
from = fromAddress,
|
||||
to = safeProxyFactoryAddress,
|
||||
proxyWallet = proxyAddress,
|
||||
data = "0x",
|
||||
nonce = null,
|
||||
signature = signatureHex,
|
||||
signatureParams = BuilderRelayerApi.SignatureParams(
|
||||
paymentToken = paymentToken,
|
||||
payment = payment,
|
||||
paymentReceiver = paymentReceiver
|
||||
),
|
||||
metadata = null
|
||||
)
|
||||
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
logger.error("Builder Relayer SAFE-CREATE 失败: code=${response.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("部署 Safe 失败: ${response.code()} - $errorBody"))
|
||||
}
|
||||
val relayerResponse = response.body()!!
|
||||
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
|
||||
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
|
||||
logger.info("Safe 部署成功: proxy=$proxyAddress, txHash=$txHash")
|
||||
Result.success(txHash)
|
||||
} catch (e: Exception) {
|
||||
logger.error("部署 Safe 失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 SignatureData 转为标准 hex 签名(0x + r(64) + s(64) + v(2),v 为 27/28)
|
||||
* 用于 SAFE-CREATE,与 viem signTypedData 输出格式一致
|
||||
*/
|
||||
private fun signatureToStandardHex(signature: org.web3j.crypto.Sign.SignatureData): String {
|
||||
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
|
||||
val v = if (vBytes != null && vBytes.isNotEmpty()) {
|
||||
vBytes[0].toInt() and 0xff
|
||||
} else {
|
||||
27
|
||||
}
|
||||
val vHex = String.format("%02x", v)
|
||||
return "0x$rHex$sHex$vHex"
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||
* 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
|
||||
|
||||
+4
-17
@@ -25,19 +25,6 @@ class SystemConfigService(
|
||||
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
|
||||
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
|
||||
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
|
||||
|
||||
/**
|
||||
* 遮蔽敏感信息,仅显示前4位和后4位
|
||||
* 例如:abcd1234...wxyz5678
|
||||
*/
|
||||
fun maskSensitiveValue(value: String?): String? {
|
||||
if (value == null) return null
|
||||
return when {
|
||||
value.length <= 8 -> "****" // 太短则完全遮蔽
|
||||
value.length <= 16 -> "${value.take(2)}...${value.takeLast(2)}"
|
||||
else -> "${value.take(4)}...${value.takeLast(4)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,10 +36,10 @@ class SystemConfigService(
|
||||
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
|
||||
val autoRedeem = isAutoRedeemEnabled()
|
||||
|
||||
// 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置)
|
||||
// 获取完整显示值(用于前端展示与编辑)
|
||||
val builderApiKeyDisplay = builderApiKey?.let {
|
||||
try {
|
||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
||||
cryptoUtils.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -60,7 +47,7 @@ class SystemConfigService(
|
||||
|
||||
val builderSecretDisplay = builderSecret?.let {
|
||||
try {
|
||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
||||
cryptoUtils.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -68,7 +55,7 @@ class SystemConfigService(
|
||||
|
||||
val builderPassphraseDisplay = builderPassphrase?.let {
|
||||
try {
|
||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
||||
cryptoUtils.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
+634
-27
@@ -26,6 +26,7 @@ import java.util.concurrent.TimeUnit
|
||||
@Service
|
||||
class TelegramNotificationService(
|
||||
private val notificationConfigService: NotificationConfigService,
|
||||
private val notificationTemplateService: NotificationTemplateService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
@@ -85,7 +86,9 @@ class TelegramNotificationService(
|
||||
marketId: String? = null,
|
||||
marketSlug: String? = null,
|
||||
side: String,
|
||||
price: String? = null, // 订单价格(可选,如果提供则直接使用)
|
||||
price: String? = null, // 订单限价(可选)
|
||||
avgFilledPrice: String? = null, // 平均成交价(可选,有成交时优先展示)
|
||||
filled: String? = null, // 已成交数量(可选,与 avgFilledPrice 一起时用于金额计算)
|
||||
size: String? = null, // 订单数量(可选,如果提供则直接使用)
|
||||
outcome: String? = null, // 市场方向(可选,如果提供则直接使用)
|
||||
accountName: String? = null,
|
||||
@@ -98,7 +101,8 @@ class TelegramNotificationService(
|
||||
locale: java.util.Locale? = null,
|
||||
leaderName: String? = null, // Leader 名称(备注)
|
||||
configName: String? = null, // 跟单配置名
|
||||
orderTime: Long? = null // 订单创建时间(毫秒时间戳),用于通知中的时间显示
|
||||
orderTime: Long? = null, // 订单创建时间(毫秒时间戳),用于通知中的时间显示
|
||||
availableBalance: String? = null // 可用余额(可选)
|
||||
) {
|
||||
// 1. 如果提供了 orderId,检查是否已发送过通知(去重)
|
||||
if (orderId != null) {
|
||||
@@ -128,14 +132,21 @@ class TelegramNotificationService(
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 优先使用传入的价格和数量,如果没有提供则尝试从订单详情获取
|
||||
var actualPrice: String? = price
|
||||
// 优先使用平均成交价(实际成交价),其次传入的限价,若未提供则从订单详情获取
|
||||
var actualPrice: String? = avgFilledPrice?.takeIf { it.isNotBlank() } ?: price
|
||||
var actualSize: String? = size
|
||||
var actualSide: String = side
|
||||
var actualOutcome: String? = outcome
|
||||
|
||||
// 有平均成交价时,已成交数量优先用 filled,用于金额计算
|
||||
val sizeForAmount: String? = if (avgFilledPrice != null && avgFilledPrice.isNotBlank() && filled != null && filled.isNotBlank()) {
|
||||
filled
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 如果价格或数量未提供,尝试从订单详情获取
|
||||
if ((actualPrice == null || actualSize == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
|
||||
// 如果价格、数量或市场方向未提供,尝试从订单详情获取
|
||||
if ((actualPrice == null || actualSize == null || actualOutcome == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
|
||||
try {
|
||||
val orderResponse = clobApi.getOrder(orderId)
|
||||
if (orderResponse.isSuccessful) {
|
||||
@@ -147,7 +158,8 @@ class TelegramNotificationService(
|
||||
if (actualSize == null) {
|
||||
actualSize = order.originalSize // 使用 originalSize 作为订单数量
|
||||
}
|
||||
actualSide = order.side // 使用订单详情中的 side
|
||||
// 注意:不覆盖 side,因为传入的 side(BUY/SELL)是正确的
|
||||
// actualSide = order.side // 不要使用订单详情中的 side,因为它可能不准确
|
||||
if (actualOutcome == null) {
|
||||
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
|
||||
}
|
||||
@@ -165,19 +177,28 @@ class TelegramNotificationService(
|
||||
|
||||
// 如果仍然没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留)
|
||||
val finalPrice = actualPrice ?: "0"
|
||||
val finalSize = actualSize ?: "0"
|
||||
// 有实际成交价时展示数量用 size_matched(filled),否则用订单数量(original_size)
|
||||
val finalSize = if (avgFilledPrice != null && avgFilledPrice.isNotBlank() && filled != null && filled.isNotBlank()) {
|
||||
filled
|
||||
} else {
|
||||
actualSize ?: "0"
|
||||
}
|
||||
// 金额计算:有实际成交价和已成交数量时用二者乘积,否则用展示价格×订单数量
|
||||
val sizeForCalc = sizeForAmount?.takeIf { it.isNotBlank() } ?: finalSize
|
||||
|
||||
// 计算订单金额 = price × size(USDC)
|
||||
val amount = try {
|
||||
val priceDecimal = finalPrice.toSafeBigDecimal()
|
||||
val sizeDecimal = finalSize.toSafeBigDecimal()
|
||||
val sizeDecimal = sizeForCalc.toSafeBigDecimal()
|
||||
priceDecimal.multiply(sizeDecimal).toString()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("计算订单金额失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
|
||||
val message = buildOrderSuccessMessage(
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" }
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" }
|
||||
val vars = buildOrderSuccessVariables(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = marketId,
|
||||
@@ -192,8 +213,12 @@ class TelegramNotificationService(
|
||||
locale = currentLocale,
|
||||
leaderName = leaderName,
|
||||
configName = configName,
|
||||
orderTime = orderTime
|
||||
orderTime = orderTime,
|
||||
availableBalance = availableBalance,
|
||||
unknownAccount = unknownAccount,
|
||||
calculateFailed = calculateFailed
|
||||
)
|
||||
val message = notificationTemplateService.renderTemplate("ORDER_SUCCESS", vars)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
@@ -232,7 +257,9 @@ class TelegramNotificationService(
|
||||
null
|
||||
}
|
||||
|
||||
val message = buildOrderFailureMessage(
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" }
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" }
|
||||
val vars = buildOrderFailureVariables(
|
||||
marketTitle = marketTitle,
|
||||
marketId = marketId,
|
||||
marketSlug = marketSlug,
|
||||
@@ -244,11 +271,65 @@ class TelegramNotificationService(
|
||||
errorMessage = errorMessage,
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
locale = currentLocale
|
||||
locale = currentLocale,
|
||||
unknownAccount = unknownAccount,
|
||||
calculateFailed = calculateFailed
|
||||
)
|
||||
val message = notificationTemplateService.renderTemplate("ORDER_FAILED", vars)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单失败通知的变量 Map
|
||||
*/
|
||||
private fun buildOrderFailureVariables(
|
||||
marketTitle: String,
|
||||
marketId: String?,
|
||||
marketSlug: String?,
|
||||
side: String,
|
||||
outcome: String?,
|
||||
price: String,
|
||||
size: String,
|
||||
amount: String?,
|
||||
errorMessage: String,
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
locale: java.util.Locale,
|
||||
unknownAccount: String,
|
||||
calculateFailed: String
|
||||
): Map<String, String> {
|
||||
val sideDisplay = when (side.uppercase()) {
|
||||
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" }
|
||||
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" }
|
||||
else -> side
|
||||
}
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val marketLink = when {
|
||||
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
|
||||
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
|
||||
else -> ""
|
||||
}
|
||||
val amountDisplay = amount?.let { am ->
|
||||
try {
|
||||
val amountDecimal = am.toSafeBigDecimal()
|
||||
(if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros()).toPlainString()
|
||||
} catch (e: Exception) { am }
|
||||
} ?: calculateFailed
|
||||
val shortError = if (errorMessage.length > 500) errorMessage.substring(0, 500) + "..." else errorMessage
|
||||
return mapOf(
|
||||
"market_title" to marketTitle.replace("<", "<").replace(">", ">"),
|
||||
"market_link" to marketLink,
|
||||
"side" to sideDisplay,
|
||||
"outcome" to (outcome?.replace("<", "<")?.replace(">", ">") ?: ""),
|
||||
"price" to formatPrice(price),
|
||||
"quantity" to formatQuantity(size),
|
||||
"amount" to amountDisplay,
|
||||
"account_name" to accountInfo,
|
||||
"error_message" to shortError.replace("<", "<").replace(">", ">"),
|
||||
"time" to DateUtils.formatDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送订单被过滤通知
|
||||
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||
@@ -285,7 +366,9 @@ class TelegramNotificationService(
|
||||
null
|
||||
}
|
||||
|
||||
val message = buildOrderFilteredMessage(
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" }
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" }
|
||||
val vars = buildOrderFilteredVariables(
|
||||
marketTitle = marketTitle,
|
||||
marketId = marketId,
|
||||
marketSlug = marketSlug,
|
||||
@@ -298,11 +381,200 @@ class TelegramNotificationService(
|
||||
filterType = filterType,
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
locale = currentLocale,
|
||||
unknownAccount = unknownAccount,
|
||||
calculateFailed = calculateFailed
|
||||
)
|
||||
val message = notificationTemplateService.renderTemplate("ORDER_FILTERED", vars)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
private fun buildOrderFilteredVariables(
|
||||
marketTitle: String,
|
||||
marketId: String?,
|
||||
marketSlug: String?,
|
||||
side: String,
|
||||
outcome: String?,
|
||||
price: String,
|
||||
size: String,
|
||||
amount: String?,
|
||||
filterReason: String,
|
||||
filterType: String,
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
locale: java.util.Locale,
|
||||
unknownAccount: String,
|
||||
calculateFailed: String
|
||||
): Map<String, String> {
|
||||
val sideDisplay = when (side.uppercase()) {
|
||||
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" }
|
||||
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" }
|
||||
else -> side
|
||||
}
|
||||
val filterTypeDisplay = when (filterType.uppercase()) {
|
||||
"ORDER_DEPTH" -> messageSource.getMessage("notification.filter.type.order_depth", null, "订单深度不足", locale).orEmpty().ifEmpty { "订单深度不足" }
|
||||
"SPREAD" -> messageSource.getMessage("notification.filter.type.spread", null, "价差过大", locale).orEmpty().ifEmpty { "价差过大" }
|
||||
"ORDERBOOK_DEPTH" -> messageSource.getMessage("notification.filter.type.orderbook_depth", null, "订单簿深度不足", locale).orEmpty().ifEmpty { "订单簿深度不足" }
|
||||
"PRICE_VALIDITY" -> messageSource.getMessage("notification.filter.type.price_validity", null, "价格不合理", locale).orEmpty().ifEmpty { "价格不合理" }
|
||||
"MARKET_STATUS" -> messageSource.getMessage("notification.filter.type.market_status", null, "市场状态不可交易", locale).orEmpty().ifEmpty { "市场状态不可交易" }
|
||||
else -> filterType
|
||||
}
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val marketLink = when {
|
||||
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
|
||||
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
|
||||
else -> ""
|
||||
}
|
||||
val amountDisplay = amount?.let { am ->
|
||||
try {
|
||||
(am.toSafeBigDecimal().let { if (it.scale() > 4) it.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else it.stripTrailingZeros() }.toPlainString())
|
||||
} catch (e: Exception) { am }
|
||||
} ?: calculateFailed
|
||||
return mapOf(
|
||||
"market_title" to marketTitle.replace("<", "<").replace(">", ">"),
|
||||
"market_link" to marketLink,
|
||||
"side" to sideDisplay,
|
||||
"outcome" to (outcome?.replace("<", "<")?.replace(">", ">") ?: ""),
|
||||
"price" to formatPrice(price),
|
||||
"quantity" to formatQuantity(size),
|
||||
"amount" to amountDisplay,
|
||||
"account_name" to accountInfo,
|
||||
"filter_type" to filterTypeDisplay,
|
||||
"filter_reason" to filterReason.replace("<", "<").replace(">", ">"),
|
||||
"time" to DateUtils.formatDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送加密价差策略下单成功通知(与跟单一致:在收到 WS 订单推送时匹配价差策略订单后调用)
|
||||
* @param price 订单限价
|
||||
* @param avgFilledPrice 平均成交价(可选,有成交时优先展示)
|
||||
* @param filled 已成交数量(可选,与 avgFilledPrice 一起时用于金额计算)
|
||||
*/
|
||||
suspend fun sendCryptoTailOrderSuccessNotification(
|
||||
orderId: String?,
|
||||
marketTitle: String,
|
||||
marketId: String? = null,
|
||||
marketSlug: String? = null,
|
||||
side: String,
|
||||
outcome: String? = null,
|
||||
price: String,
|
||||
size: String,
|
||||
avgFilledPrice: String? = null,
|
||||
filled: String? = null,
|
||||
strategyName: String? = null,
|
||||
accountName: String? = null,
|
||||
walletAddress: String? = null,
|
||||
locale: java.util.Locale? = null,
|
||||
orderTime: Long? = null
|
||||
) {
|
||||
if (orderId != null) {
|
||||
val lastSentTime = sentOrderIds[orderId]
|
||||
if (lastSentTime != null && System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) {
|
||||
logger.info("加密价差策略订单通知已发送过(5分钟内),跳过: orderId=$orderId")
|
||||
return
|
||||
}
|
||||
sentOrderIds[orderId] = System.currentTimeMillis()
|
||||
if (sentOrderIds.size > 1000) {
|
||||
val expiryTime = System.currentTimeMillis() - 5 * 60 * 1000
|
||||
sentOrderIds.entries.removeIf { it.value < expiryTime }
|
||||
}
|
||||
}
|
||||
val currentLocale = locale ?: try {
|
||||
LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e)
|
||||
java.util.Locale("zh", "CN")
|
||||
}
|
||||
val displayPrice = avgFilledPrice?.takeIf { it.isNotBlank() } ?: price
|
||||
val hasAvgFilled = avgFilledPrice != null && avgFilledPrice.isNotBlank() && filled != null && filled.isNotBlank()
|
||||
val sizeForAmount = if (hasAvgFilled) filled else size
|
||||
val quantityDisplay = if (hasAvgFilled) filled else size // 有实际成交价时展示数量用 size_matched
|
||||
val amount = try {
|
||||
val priceDecimal = displayPrice.toSafeBigDecimal()
|
||||
val sizeDecimal = sizeForAmount.toSafeBigDecimal()
|
||||
priceDecimal.multiply(sizeDecimal).toString()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("计算订单金额失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
val unknown = messageSource.getMessage("common.unknown", null, "未知", currentLocale).orEmpty().ifEmpty { "未知" }
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" }
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" }
|
||||
val vars = buildCryptoTailOrderSuccessVariables(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = side,
|
||||
outcome = outcome,
|
||||
price = displayPrice,
|
||||
size = quantityDisplay.orEmpty(),
|
||||
amount = amount,
|
||||
strategyName = strategyName,
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
orderTime = orderTime,
|
||||
unknown = unknown,
|
||||
unknownAccount = unknownAccount,
|
||||
calculateFailed = calculateFailed,
|
||||
locale = currentLocale
|
||||
)
|
||||
val message = notificationTemplateService.renderTemplate("CRYPTO_TAIL_SUCCESS", vars)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
private fun buildCryptoTailOrderSuccessVariables(
|
||||
orderId: String?,
|
||||
marketTitle: String,
|
||||
marketId: String?,
|
||||
marketSlug: String?,
|
||||
side: String,
|
||||
outcome: String?,
|
||||
price: String,
|
||||
size: String,
|
||||
amount: String?,
|
||||
strategyName: String?,
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
orderTime: Long?,
|
||||
unknown: String,
|
||||
unknownAccount: String,
|
||||
calculateFailed: String,
|
||||
locale: java.util.Locale
|
||||
): Map<String, String> {
|
||||
val sideDisplay = when (side.uppercase()) {
|
||||
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" }
|
||||
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" }
|
||||
else -> side
|
||||
}
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime()
|
||||
val marketLink = when {
|
||||
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
|
||||
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
|
||||
else -> ""
|
||||
}
|
||||
val amountDisplay = amount?.let { am ->
|
||||
try {
|
||||
(am.toSafeBigDecimal().let { if (it.scale() > 4) it.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else it.stripTrailingZeros() }.toPlainString())
|
||||
} catch (e: Exception) { am }
|
||||
} ?: calculateFailed
|
||||
return mapOf(
|
||||
"order_id" to (orderId ?: unknown),
|
||||
"market_title" to marketTitle.replace("<", "<").replace(">", ">"),
|
||||
"market_link" to marketLink,
|
||||
"side" to sideDisplay,
|
||||
"outcome" to (outcome?.replace("<", "<")?.replace(">", ">") ?: ""),
|
||||
"price" to formatPrice(price),
|
||||
"quantity" to formatQuantity(size),
|
||||
"amount" to amountDisplay,
|
||||
"account_name" to accountInfo,
|
||||
"strategy_name" to (strategyName?.takeIf { it.isNotBlank() } ?: unknown),
|
||||
"time" to time
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单被过滤消息
|
||||
*/
|
||||
@@ -419,7 +691,7 @@ class TelegramNotificationService(
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $amountLabel: <code>${'$'}$amountDisplay</code>
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
⚠️ <b>$filterTypeLabel:</b> <code>$filterTypeDisplay</code>
|
||||
@@ -685,6 +957,76 @@ class TelegramNotificationService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单成功通知的变量 Map(供模板渲染)
|
||||
*/
|
||||
private fun buildOrderSuccessVariables(
|
||||
orderId: String?,
|
||||
marketTitle: String,
|
||||
marketId: String?,
|
||||
marketSlug: String?,
|
||||
side: String,
|
||||
outcome: String?,
|
||||
price: String,
|
||||
size: String,
|
||||
amount: String?,
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
locale: java.util.Locale,
|
||||
leaderName: String?,
|
||||
configName: String?,
|
||||
orderTime: Long?,
|
||||
availableBalance: String?,
|
||||
unknownAccount: String,
|
||||
calculateFailed: String
|
||||
): Map<String, String> {
|
||||
val sideDisplay = when (side.uppercase()) {
|
||||
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" }
|
||||
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" }
|
||||
else -> side
|
||||
}
|
||||
val unknown = messageSource.getMessage("common.unknown", null, "未知", locale).orEmpty().ifEmpty { "未知" }
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime()
|
||||
val marketLink = when {
|
||||
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
|
||||
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
|
||||
else -> ""
|
||||
}
|
||||
val amountDisplay = when {
|
||||
amount != null -> try {
|
||||
val amountDecimal = amount.toSafeBigDecimal()
|
||||
val formatted = if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros()
|
||||
formatted.toPlainString()
|
||||
} catch (e: Exception) { amount ?: calculateFailed }
|
||||
else -> calculateFailed
|
||||
}
|
||||
val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) {
|
||||
try {
|
||||
val balanceDecimal = availableBalance.toSafeBigDecimal()
|
||||
val formatted = if (balanceDecimal.scale() > 4) balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else balanceDecimal.stripTrailingZeros()
|
||||
formatted.toPlainString()
|
||||
} catch (e: Exception) { availableBalance ?: "" }
|
||||
} else { "" }
|
||||
val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">")
|
||||
val escapedOutcome = outcome?.replace("<", "<")?.replace(">", ">") ?: ""
|
||||
return mapOf(
|
||||
"order_id" to (orderId ?: unknown),
|
||||
"market_title" to escapedMarketTitle,
|
||||
"market_link" to marketLink,
|
||||
"side" to sideDisplay,
|
||||
"outcome" to escapedOutcome,
|
||||
"price" to formatPrice(price),
|
||||
"quantity" to formatQuantity(size),
|
||||
"amount" to amountDisplay,
|
||||
"account_name" to accountInfo,
|
||||
"available_balance" to availableBalanceDisplay,
|
||||
"leader_name" to (leaderName ?: ""),
|
||||
"config_name" to (configName ?: ""),
|
||||
"time" to time
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单成功消息
|
||||
*/
|
||||
@@ -703,7 +1045,8 @@ class TelegramNotificationService(
|
||||
locale: java.util.Locale,
|
||||
leaderName: String? = null, // Leader 名称(备注)
|
||||
configName: String? = null, // 跟单配置名
|
||||
orderTime: Long? = null // 订单创建时间(毫秒时间戳)
|
||||
orderTime: Long? = null, // 订单创建时间(毫秒时间戳)
|
||||
availableBalance: String? = null // 可用余额
|
||||
): String {
|
||||
|
||||
// 获取多语言文本
|
||||
@@ -718,6 +1061,7 @@ class TelegramNotificationService(
|
||||
val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
|
||||
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
|
||||
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
||||
val availableBalanceLabel = messageSource.getMessage("notification.order.available_balance", null, "可用余额", locale)
|
||||
val unknown = messageSource.getMessage("common.unknown", null, "未知", locale)
|
||||
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale)
|
||||
@@ -816,6 +1160,23 @@ class TelegramNotificationService(
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
|
||||
// 格式化可用余额
|
||||
val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) {
|
||||
try {
|
||||
val balanceDecimal = availableBalance.toSafeBigDecimal()
|
||||
val formatted = if (balanceDecimal.scale() > 4) {
|
||||
balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
|
||||
} else {
|
||||
balanceDecimal.stripTrailingZeros()
|
||||
}
|
||||
"\n• $availableBalanceLabel: <code>${'$'}${formatted.toPlainString()}</code>"
|
||||
} catch (e: Exception) {
|
||||
"\n• $availableBalanceLabel: <code>${'$'}$availableBalance</code>"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
return """$icon <b>$orderCreatedSuccess</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
@@ -824,8 +1185,87 @@ class TelegramNotificationService(
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
|
||||
• $amountLabel: <code>${'$'}$amountDisplay</code>
|
||||
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo$availableBalanceDisplay
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建加密价差策略下单成功消息(与订单成功格式一致,增加「加密价差策略」标题与策略名)
|
||||
*/
|
||||
private fun buildCryptoTailOrderSuccessMessage(
|
||||
orderId: String?,
|
||||
marketTitle: String,
|
||||
marketId: String?,
|
||||
marketSlug: String?,
|
||||
side: String,
|
||||
outcome: String?,
|
||||
price: String,
|
||||
size: String,
|
||||
amount: String?,
|
||||
strategyName: String?,
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
locale: java.util.Locale,
|
||||
orderTime: Long?
|
||||
): String {
|
||||
val tailOrderSuccess = messageSource.getMessage("notification.tail.order.success", null, "加密价差策略下单成功", locale)
|
||||
val strategyLabel = messageSource.getMessage("notification.tail.strategy", null, "策略", locale)
|
||||
val orderInfo = messageSource.getMessage("notification.order.info", null, "订单信息", locale)
|
||||
val orderIdLabel = messageSource.getMessage("notification.order.id", null, "订单ID", locale)
|
||||
val marketLabel = messageSource.getMessage("notification.order.market", null, "市场", locale)
|
||||
val sideLabel = messageSource.getMessage("notification.order.side", null, "方向", locale)
|
||||
val outcomeLabel = messageSource.getMessage("notification.order.outcome", null, "市场方向", locale)
|
||||
val priceLabel = messageSource.getMessage("notification.order.price", null, "价格", locale)
|
||||
val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale)
|
||||
val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
|
||||
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
|
||||
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
||||
val unknown: String = messageSource.getMessage("common.unknown", null, "未知", locale) ?: "未知"
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale)
|
||||
val sideDisplay = when (side.uppercase()) {
|
||||
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale)
|
||||
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale)
|
||||
else -> side
|
||||
}
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime()
|
||||
val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">")
|
||||
val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">")
|
||||
val strategyDisplay = strategyName?.takeIf { it.isNotBlank() } ?: unknown
|
||||
val escapedStrategyName = strategyDisplay.replace("<", "<").replace(">", ">")
|
||||
val amountDisplay = if (amount != null) {
|
||||
try {
|
||||
val amountDecimal = amount.toSafeBigDecimal()
|
||||
val formatted = if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros()
|
||||
formatted.toPlainString()
|
||||
} catch (e: Exception) { amount }
|
||||
} else calculateFailed
|
||||
val marketLink = when {
|
||||
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
|
||||
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
|
||||
else -> null
|
||||
}
|
||||
val marketDisplay = if (marketLink != null) "<a href=\"$marketLink\">$escapedMarketTitle</a>" else escapedMarketTitle
|
||||
val outcomeDisplay = if (!outcome.isNullOrBlank()) {
|
||||
val escapedOutcome = outcome.replace("<", "<").replace(">", ">")
|
||||
"\n• $outcomeLabel: <b>$escapedOutcome</b>"
|
||||
} else ""
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
return """🚀 <b>$tailOrderSuccess</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
• $orderIdLabel: <code>${orderId ?: unknown}</code>
|
||||
• $strategyLabel: $escapedStrategyName
|
||||
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>${'$'}$amountDisplay</code>
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
@@ -941,7 +1381,7 @@ class TelegramNotificationService(
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $amountLabel: <code>${'$'}$amountDisplay</code>
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
⚠️ <b>$errorInfo:</b>
|
||||
@@ -953,6 +1393,7 @@ class TelegramNotificationService(
|
||||
/**
|
||||
* 发送仓位赎回通知
|
||||
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||
* @param availableBalance 可用余额(可选)
|
||||
*/
|
||||
suspend fun sendRedeemNotification(
|
||||
accountName: String?,
|
||||
@@ -960,7 +1401,8 @@ class TelegramNotificationService(
|
||||
transactionHash: String,
|
||||
totalRedeemedValue: String,
|
||||
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
||||
locale: java.util.Locale? = null
|
||||
locale: java.util.Locale? = null,
|
||||
availableBalance: String? = null
|
||||
) {
|
||||
// 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取)
|
||||
val currentLocale = locale ?: try {
|
||||
@@ -970,16 +1412,46 @@ class TelegramNotificationService(
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
val message = buildRedeemMessage(
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale) ?: "未知账户"
|
||||
val vars = buildRedeemSuccessVariables(
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
transactionHash = transactionHash,
|
||||
totalRedeemedValue = totalRedeemedValue,
|
||||
positions = positions,
|
||||
locale = currentLocale
|
||||
availableBalance = availableBalance,
|
||||
unknownAccount = unknownAccount
|
||||
)
|
||||
val message = notificationTemplateService.renderTemplate("REDEEM_SUCCESS", vars)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
private fun buildRedeemSuccessVariables(
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
transactionHash: String,
|
||||
totalRedeemedValue: String,
|
||||
availableBalance: String?,
|
||||
unknownAccount: String
|
||||
): Map<String, String> {
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val totalValueDisplay = try {
|
||||
val d = totalRedeemedValue.toSafeBigDecimal()
|
||||
(if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString()
|
||||
} catch (e: Exception) { totalRedeemedValue }
|
||||
val availableBalanceDisplay = availableBalance?.let { ab ->
|
||||
try {
|
||||
val d = ab.toSafeBigDecimal()
|
||||
(if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString()
|
||||
} catch (e: Exception) { ab }
|
||||
} ?: ""
|
||||
return mapOf(
|
||||
"account_name" to accountInfo,
|
||||
"transaction_hash" to transactionHash.replace("<", "<").replace(">", ">"),
|
||||
"total_value" to totalValueDisplay,
|
||||
"available_balance" to availableBalanceDisplay,
|
||||
"time" to DateUtils.formatDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建仓位赎回消息
|
||||
@@ -990,7 +1462,8 @@ class TelegramNotificationService(
|
||||
transactionHash: String,
|
||||
totalRedeemedValue: String,
|
||||
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
||||
locale: java.util.Locale
|
||||
locale: java.util.Locale,
|
||||
availableBalance: String? = null
|
||||
): String {
|
||||
// 获取多语言文本
|
||||
val redeemSuccess = messageSource.getMessage("notification.redeem.success", null, "仓位赎回成功", locale)
|
||||
@@ -1003,6 +1476,7 @@ class TelegramNotificationService(
|
||||
val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale)
|
||||
val valueLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
|
||||
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
||||
val availableBalanceLabel = messageSource.getMessage("notification.redeem.available_balance", null, "可用余额", locale)
|
||||
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||
|
||||
// 构建账户信息(格式:账户名(钱包地址))
|
||||
@@ -1041,7 +1515,24 @@ class TelegramNotificationService(
|
||||
} catch (e: Exception) {
|
||||
position.value
|
||||
}
|
||||
" • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC"
|
||||
" • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = ${'$'}$valueDisplay"
|
||||
}
|
||||
|
||||
// 格式化可用余额
|
||||
val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) {
|
||||
try {
|
||||
val balanceDecimal = availableBalance.toSafeBigDecimal()
|
||||
val formatted = if (balanceDecimal.scale() > 4) {
|
||||
balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
|
||||
} else {
|
||||
balanceDecimal.stripTrailingZeros()
|
||||
}
|
||||
"\n• $availableBalanceLabel: <code>${'$'}${formatted.toPlainString()}</code>"
|
||||
} catch (e: Exception) {
|
||||
"\n• $availableBalanceLabel: <code>${'$'}$availableBalance</code>"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
return """💸 <b>$redeemSuccess</b>
|
||||
@@ -1049,14 +1540,130 @@ class TelegramNotificationService(
|
||||
📊 <b>$redeemInfo:</b>
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
• $transactionHashLabel: <code>$escapedTxHash</code>
|
||||
• $totalValueLabel: <code>$totalValueDisplay</code> USDC
|
||||
• $totalValueLabel: <code>${'$'}$totalValueDisplay</code>$availableBalanceDisplay
|
||||
|
||||
📦 <b>$positionsLabel:</b>
|
||||
$positionsText
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 发送仓位已结算(无收益)通知
|
||||
* 用于输的仓位,赎回价值为 0 的情况
|
||||
*/
|
||||
suspend fun sendRedeemNoReturnNotification(
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
transactionHash: String,
|
||||
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
||||
locale: java.util.Locale? = null,
|
||||
availableBalance: String? = null
|
||||
) {
|
||||
val currentLocale = locale ?: try {
|
||||
LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e)
|
||||
java.util.Locale("zh", "CN")
|
||||
}
|
||||
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale) ?: "未知账户"
|
||||
val vars = buildRedeemNoReturnVariables(
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
transactionHash = transactionHash,
|
||||
availableBalance = availableBalance,
|
||||
unknownAccount = unknownAccount
|
||||
)
|
||||
val message = notificationTemplateService.renderTemplate("REDEEM_NO_RETURN", vars)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
private fun buildRedeemNoReturnVariables(
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
transactionHash: String,
|
||||
availableBalance: String?,
|
||||
unknownAccount: String
|
||||
): Map<String, String> {
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val availableBalanceDisplay = availableBalance?.let { ab ->
|
||||
try {
|
||||
val d = ab.toSafeBigDecimal()
|
||||
(if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString()
|
||||
} catch (e: Exception) { ab }
|
||||
} ?: ""
|
||||
return mapOf(
|
||||
"account_name" to accountInfo,
|
||||
"transaction_hash" to transactionHash.replace("<", "<").replace(">", ">"),
|
||||
"available_balance" to availableBalanceDisplay,
|
||||
"time" to DateUtils.formatDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建仓位已结算(无收益)消息
|
||||
*/
|
||||
private fun buildRedeemNoReturnMessage(
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
transactionHash: String,
|
||||
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
||||
locale: java.util.Locale,
|
||||
availableBalance: String? = null
|
||||
): String {
|
||||
val noReturnTitle = messageSource.getMessage("notification.redeem.no_return.title", null, "仓位已结算(无收益)", locale)
|
||||
val noReturnInfo = messageSource.getMessage("notification.redeem.no_return.info", null, "结算信息", locale)
|
||||
val noReturnMessage = messageSource.getMessage("notification.redeem.no_return.message", null, "市场已结算,您的预测未命中,赎回价值为 0。", locale)
|
||||
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
|
||||
val transactionHashLabel = messageSource.getMessage("notification.redeem.transaction_hash", null, "交易哈希", locale)
|
||||
val positionsLabel = messageSource.getMessage("notification.redeem.no_return.positions", null, "结算仓位", locale)
|
||||
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
||||
val availableBalanceLabel = messageSource.getMessage("notification.redeem.available_balance", null, "可用余额", locale)
|
||||
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">")
|
||||
val escapedTxHash = transactionHash.replace("<", "<").replace(">", ">")
|
||||
|
||||
val positionsText = positions.joinToString("\n") { position ->
|
||||
val quantityDisplay = formatQuantity(position.quantity)
|
||||
" • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares"
|
||||
}
|
||||
|
||||
// 格式化可用余额
|
||||
val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) {
|
||||
try {
|
||||
val balanceDecimal = availableBalance.toSafeBigDecimal()
|
||||
val formatted = if (balanceDecimal.scale() > 4) {
|
||||
balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
|
||||
} else {
|
||||
balanceDecimal.stripTrailingZeros()
|
||||
}
|
||||
"\n• $availableBalanceLabel: <code>${'$'}${formatted.toPlainString()}</code>"
|
||||
} catch (e: Exception) {
|
||||
"\n• $availableBalanceLabel: <code>${'$'}$availableBalance</code>"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
return """📋 <b>$noReturnTitle</b>
|
||||
|
||||
📊 <b>$noReturnInfo:</b>
|
||||
<i>$noReturnMessage</i>
|
||||
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
• $transactionHashLabel: <code>$escapedTxHash</code>$availableBalanceDisplay
|
||||
|
||||
📦 <b>$positionsLabel:</b>
|
||||
$positionsText
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏显示地址(只显示前6位和后4位)
|
||||
*/
|
||||
|
||||
@@ -167,9 +167,8 @@ object Eip712Encoder {
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 ExchangeOrder 域分隔符
|
||||
* 参考: @polymarket/order-utils 的 ExchangeOrderBuilder
|
||||
* Domain: { name: "Polymarket CTF Exchange", version: "1", chainId: chainId, verifyingContract: exchangeContract }
|
||||
* 编码 ExchangeOrder V2 域分隔符
|
||||
* Domain: { name: "Polymarket CTF Exchange", version: "2", chainId: chainId, verifyingContract: exchangeContract }
|
||||
*/
|
||||
fun encodeExchangeDomain(
|
||||
chainId: Long,
|
||||
@@ -184,9 +183,9 @@ object Eip712Encoder {
|
||||
"verifyingContract" to "address"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
val nameHash = encodeString("Polymarket CTF Exchange")
|
||||
val versionHash = encodeString("1")
|
||||
val versionHash = encodeString("2")
|
||||
val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId))
|
||||
val contractBytes = encodeAddress(verifyingContract)
|
||||
|
||||
@@ -201,23 +200,21 @@ object Eip712Encoder {
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 ExchangeOrder 消息哈希
|
||||
* 参考: @polymarket/order-utils 的 ExchangeOrderBuilder
|
||||
* Order: { salt, maker, signer, taker, tokenId, makerAmount, takerAmount, expiration, nonce, feeRateBps, side, signatureType }
|
||||
* 编码 ExchangeOrder V2 消息哈希
|
||||
* V2 Order: { salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder }
|
||||
*/
|
||||
fun encodeExchangeOrder(
|
||||
salt: Long,
|
||||
maker: String,
|
||||
signer: String,
|
||||
taker: String,
|
||||
tokenId: String,
|
||||
makerAmount: String,
|
||||
takerAmount: String,
|
||||
expiration: String,
|
||||
nonce: String,
|
||||
feeRateBps: String,
|
||||
side: String,
|
||||
signatureType: Int
|
||||
signatureType: Int,
|
||||
timestamp: String,
|
||||
metadata: String,
|
||||
builder: String
|
||||
): ByteArray {
|
||||
val orderTypeHash = encodeType(
|
||||
"Order",
|
||||
@@ -225,57 +222,51 @@ object Eip712Encoder {
|
||||
"salt" to "uint256",
|
||||
"maker" to "address",
|
||||
"signer" to "address",
|
||||
"taker" to "address",
|
||||
"tokenId" to "uint256",
|
||||
"makerAmount" to "uint256",
|
||||
"takerAmount" to "uint256",
|
||||
"expiration" to "uint256",
|
||||
"nonce" to "uint256",
|
||||
"feeRateBps" to "uint256",
|
||||
"side" to "uint8",
|
||||
"signatureType" to "uint8"
|
||||
"signatureType" to "uint8",
|
||||
"timestamp" to "uint256",
|
||||
"metadata" to "bytes32",
|
||||
"builder" to "bytes32"
|
||||
)
|
||||
)
|
||||
|
||||
// 编码订单字段
|
||||
|
||||
val saltBytes = encodeUint256(BigInteger.valueOf(salt))
|
||||
val makerBytes = encodeAddress(maker)
|
||||
val signerBytes = encodeAddress(signer)
|
||||
val takerBytes = encodeAddress(taker)
|
||||
val tokenIdBytes = encodeUint256(BigInteger(tokenId))
|
||||
val makerAmountBytes = encodeUint256(BigInteger(makerAmount))
|
||||
val takerAmountBytes = encodeUint256(BigInteger(takerAmount))
|
||||
val expirationBytes = encodeUint256(BigInteger(expiration))
|
||||
val nonceBytes = encodeUint256(BigInteger(nonce))
|
||||
val feeRateBpsBytes = encodeUint256(BigInteger(feeRateBps))
|
||||
|
||||
// side: BUY = 0, SELL = 1 (uint8,但需要编码为 32 字节)
|
||||
|
||||
val sideValue = when (side.uppercase()) {
|
||||
"BUY" -> 0
|
||||
"SELL" -> 1
|
||||
else -> throw IllegalArgumentException("side 必须是 BUY 或 SELL")
|
||||
}
|
||||
// uint8 类型,但 EIP-712 编码时仍需要 32 字节
|
||||
val sideBytes = encodeUint256(BigInteger.valueOf(sideValue.toLong()))
|
||||
val signatureTypeBytes = encodeUint256(BigInteger.valueOf(signatureType.toLong()))
|
||||
|
||||
// 组合所有字段
|
||||
val encoded = ByteArray(32 * 13) // 13 个字段,每个 32 字节
|
||||
|
||||
val timestampBytes = encodeUint256(BigInteger(timestamp))
|
||||
val metadataBytes = Numeric.hexStringToByteArray(metadata.removePrefix("0x").padStart(64, '0'))
|
||||
val builderBytes = Numeric.hexStringToByteArray(builder.removePrefix("0x").padStart(64, '0'))
|
||||
|
||||
val encoded = ByteArray(32 * 12) // typeHash + 11 个字段
|
||||
var offset = 0
|
||||
System.arraycopy(orderTypeHash, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(saltBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(makerBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(signerBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(takerBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(tokenIdBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(makerAmountBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(takerAmountBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(expirationBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(nonceBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(feeRateBpsBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(sideBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(signatureTypeBytes, 0, encoded, offset, 32)
|
||||
|
||||
System.arraycopy(signatureTypeBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(timestampBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(metadataBytes, 0, encoded, offset, 32); offset += 32
|
||||
System.arraycopy(builderBytes, 0, encoded, offset, 32)
|
||||
|
||||
return keccak256(encoded)
|
||||
}
|
||||
|
||||
@@ -377,5 +368,62 @@ object Eip712Encoder {
|
||||
|
||||
return keccak256(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* SafeCreate 用 EIP712 域(Polymarket Contract Proxy Factory)
|
||||
* Domain: EIP712Domain(string name, uint256 chainId, address verifyingContract)
|
||||
* 参考: builder-relayer-client/src/builder/create.ts createSafeCreateSignature
|
||||
*/
|
||||
fun encodeSafeCreateDomain(
|
||||
name: String,
|
||||
chainId: Long,
|
||||
verifyingContract: String
|
||||
): ByteArray {
|
||||
val domainTypeHash = encodeType(
|
||||
"EIP712Domain",
|
||||
listOf(
|
||||
"name" to "string",
|
||||
"chainId" to "uint256",
|
||||
"verifyingContract" to "address"
|
||||
)
|
||||
)
|
||||
val nameHash = encodeString(name)
|
||||
val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId))
|
||||
val contractBytes = encodeAddress(verifyingContract)
|
||||
val encoded = ByteArray(32 + 32 + 32 + 32)
|
||||
System.arraycopy(domainTypeHash, 0, encoded, 0, 32)
|
||||
System.arraycopy(nameHash, 0, encoded, 32, 32)
|
||||
System.arraycopy(chainIdBytes, 0, encoded, 64, 32)
|
||||
System.arraycopy(contractBytes, 0, encoded, 96, 32)
|
||||
return keccak256(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateProxy 消息哈希(SafeCreate 签名用)
|
||||
* CreateProxy(address paymentToken, uint256 payment, address paymentReceiver)
|
||||
*/
|
||||
fun encodeCreateProxyMessage(
|
||||
paymentToken: String,
|
||||
payment: BigInteger,
|
||||
paymentReceiver: String
|
||||
): ByteArray {
|
||||
val typeHash = encodeType(
|
||||
"CreateProxy",
|
||||
listOf(
|
||||
"paymentToken" to "address",
|
||||
"payment" to "uint256",
|
||||
"paymentReceiver" to "address"
|
||||
)
|
||||
)
|
||||
val tokenBytes = encodeAddress(paymentToken)
|
||||
val paymentBytes = encodeUint256(payment)
|
||||
val receiverBytes = encodeAddress(paymentReceiver)
|
||||
val encoded = ByteArray(32 + 32 + 32 + 32)
|
||||
System.arraycopy(typeHash, 0, encoded, 0, 32)
|
||||
System.arraycopy(tokenBytes, 0, encoded, 32, 32)
|
||||
System.arraycopy(paymentBytes, 0, encoded, 64, 32)
|
||||
System.arraycopy(receiverBytes, 0, encoded, 96, 32)
|
||||
return keccak256(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import java.math.BigInteger
|
||||
object EthereumUtils {
|
||||
|
||||
// Polymarket 合约地址(Polygon 主网)
|
||||
private val COLLATERAL_TOKEN_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" // USDC
|
||||
private val COLLATERAL_TOKEN_ADDRESS = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // pUSD
|
||||
private val CONDITIONAL_TOKENS_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" // ConditionalTokens
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.BinanceApi
|
||||
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.GitHubApi
|
||||
@@ -300,7 +301,18 @@ class RetrofitFactory(
|
||||
fun createDataApi(): PolymarketDataApi {
|
||||
return dataApi
|
||||
}
|
||||
|
||||
|
||||
private val binanceApi: BinanceApi by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl("https://api.binance.com/")
|
||||
.client(sharedOkHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(BinanceApi::class.java)
|
||||
}
|
||||
|
||||
fun createBinanceApi(): BinanceApi = binanceApi
|
||||
|
||||
/**
|
||||
* 创建 Builder Relayer API 客户端
|
||||
* 按 relayerUrl 缓存,避免重复创建
|
||||
@@ -388,8 +400,6 @@ class RpcUrlReplaceInterceptor(
|
||||
private val fixedBaseUrl: String,
|
||||
private val actualRpcUrl: String
|
||||
) : Interceptor {
|
||||
private val logger = LoggerFactory.getLogger(RpcUrlReplaceInterceptor::class.java)
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
@@ -403,8 +413,6 @@ class RpcUrlReplaceInterceptor(
|
||||
val newUrl = newUrlString.toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString")
|
||||
|
||||
logger.debug("RPC URL 替换: $originalUrlString -> $newUrlString")
|
||||
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
|
||||
+1
-2
@@ -193,7 +193,7 @@ class UnifiedWebSocketHandler(
|
||||
lastActivityTime.remove(sessionId)
|
||||
sessionLocks.remove(sessionId) // 清理同步锁
|
||||
subscriptionService.unregisterSession(sessionId)
|
||||
|
||||
|
||||
if (session != null && session.isOpen) {
|
||||
try {
|
||||
session.close(CloseStatus.NORMAL)
|
||||
@@ -201,7 +201,6 @@ class UnifiedWebSocketHandler(
|
||||
// 忽略关闭时的异常
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("清理 WebSocket 资源时发生错误: $sessionId, ${e.message}", e)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- ============================================
|
||||
-- V32: 添加回测价格区间过滤字段
|
||||
-- 用于配置价格区间,仅在指定价格区间内的订单才会跟单
|
||||
-- ============================================
|
||||
|
||||
-- 添加价格区间字段到回测任务表
|
||||
ALTER TABLE backtest_task
|
||||
ADD COLUMN min_price DECIMAL(20, 8) NULL COMMENT '最低价格(可选),NULL表示不限制最低价',
|
||||
ADD COLUMN max_price DECIMAL(20, 8) NULL COMMENT '最高价格(可选),NULL表示不限制最高价';
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
-- ============================================
|
||||
-- V33: 唯一约束从 wallet_address 改为 proxy_address
|
||||
-- 允许同一 EOA 以不同代理类型(Magic/Safe)各导入一个账户,按代理地址去重
|
||||
-- ============================================
|
||||
|
||||
-- 将已存在账户的 wallet_type 统一为 safe(历史数据兼容)
|
||||
UPDATE wallet_accounts SET wallet_type = 'safe';
|
||||
|
||||
-- 删除 wallet_address 上的唯一约束(通过 KEY_COLUMN_USAGE 定位到该列的约束名)
|
||||
SET @uk_name = (SELECT kcu.CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
|
||||
ON kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA AND kcu.TABLE_NAME = tc.TABLE_NAME AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
|
||||
WHERE kcu.TABLE_SCHEMA = DATABASE()
|
||||
AND kcu.TABLE_NAME = 'wallet_accounts'
|
||||
AND tc.CONSTRAINT_TYPE = 'UNIQUE'
|
||||
AND kcu.COLUMN_NAME = 'wallet_address'
|
||||
LIMIT 1);
|
||||
SET @sql = IF(@uk_name IS NOT NULL,
|
||||
CONCAT('ALTER TABLE wallet_accounts DROP INDEX ', @uk_name),
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 为 proxy_address 添加唯一约束(若已存在则跳过)
|
||||
SET @uk_exists = (SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'wallet_accounts'
|
||||
AND CONSTRAINT_TYPE = 'UNIQUE'
|
||||
AND CONSTRAINT_NAME = 'uk_wallet_accounts_proxy_address'
|
||||
LIMIT 1);
|
||||
SET @sql2 = IF(@uk_exists IS NULL,
|
||||
'ALTER TABLE wallet_accounts ADD UNIQUE KEY uk_wallet_accounts_proxy_address (proxy_address)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt2 FROM @sql2;
|
||||
EXECUTE stmt2;
|
||||
DEALLOCATE PREPARE stmt2;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- ============================================
|
||||
-- V34: 加密市场尾盘策略表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS crypto_tail_strategy (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '策略ID',
|
||||
account_id BIGINT NOT NULL COMMENT '钱包账户ID',
|
||||
name VARCHAR(255) DEFAULT NULL COMMENT '策略名称(可选,用于列表展示)',
|
||||
market_slug_prefix VARCHAR(64) NOT NULL COMMENT '市场 slug 前缀,如 btc-updown-5m、btc-updown-15m',
|
||||
interval_seconds INT NOT NULL COMMENT '周期长度秒数:300(5分钟) 或 900(15分钟)',
|
||||
window_start_seconds INT NOT NULL COMMENT '时间窗口开始秒数(相对周期起点)',
|
||||
window_end_seconds INT NOT NULL COMMENT '时间窗口结束秒数(相对周期起点)',
|
||||
min_price DECIMAL(20, 8) NOT NULL COMMENT '最低触发价格 0~1',
|
||||
max_price DECIMAL(20, 8) NOT NULL DEFAULT 1 COMMENT '最高触发价格 0~1,默认1',
|
||||
amount_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '投入方式: RATIO=按比例, FIXED=固定金额',
|
||||
amount_value DECIMAL(20, 8) NOT NULL COMMENT '比例(0~100)或固定USDC金额',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用: 0=停用, 1=启用',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间',
|
||||
INDEX idx_account_id (account_id),
|
||||
INDEX idx_enabled (enabled),
|
||||
FOREIGN KEY (account_id) REFERENCES wallet_accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='加密市场尾盘策略表';
|
||||
|
||||
-- ============================================
|
||||
-- 触发记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS crypto_tail_strategy_trigger (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
|
||||
strategy_id BIGINT NOT NULL COMMENT '策略ID',
|
||||
period_start_unix BIGINT NOT NULL COMMENT '周期起点 Unix 秒',
|
||||
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
|
||||
outcome_index INT NOT NULL COMMENT '方向: 0=Up, 1=Down',
|
||||
trigger_price DECIMAL(20, 8) NOT NULL COMMENT '触发时价格',
|
||||
amount_usdc DECIMAL(20, 8) NOT NULL COMMENT '投入金额 USDC',
|
||||
order_id VARCHAR(128) DEFAULT NULL COMMENT '订单ID(成功时有值)',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '状态: success, fail',
|
||||
fail_reason VARCHAR(500) DEFAULT NULL COMMENT '失败原因',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
INDEX idx_strategy_id (strategy_id),
|
||||
INDEX idx_period (strategy_id, period_start_unix),
|
||||
INDEX idx_created_at (created_at),
|
||||
FOREIGN KEY (strategy_id) REFERENCES crypto_tail_strategy(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='尾盘策略触发记录表';
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
-- ============================================
|
||||
-- V35: 尾盘策略触发记录 - 结算与收益字段
|
||||
-- 用于轮询服务:扫描 success 但未结算的订单,查链上结算结果并回写收益
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE crypto_tail_strategy_trigger
|
||||
ADD COLUMN condition_id VARCHAR(66) DEFAULT NULL COMMENT '市场 conditionId(用于查链上结算)' AFTER order_id,
|
||||
ADD COLUMN resolved TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已结算: 0=未结算, 1=已结算',
|
||||
ADD COLUMN winner_outcome_index INT DEFAULT NULL COMMENT '市场赢家 outcome 索引(结算后写入)',
|
||||
ADD COLUMN realized_pnl DECIMAL(20, 8) DEFAULT NULL COMMENT '已实现盈亏 USDC(赢为正,输为负)',
|
||||
ADD COLUMN settled_at BIGINT DEFAULT NULL COMMENT '结算时间(毫秒时间戳)';
|
||||
|
||||
CREATE INDEX idx_trigger_settlement ON crypto_tail_strategy_trigger (status, resolved);
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- ============================================
|
||||
-- V36: 尾盘策略触发记录 - TG 通知已发标记(与跟单轮询发 TG 一致)
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE crypto_tail_strategy_trigger
|
||||
ADD COLUMN notification_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已发送 TG 通知: 0=未发送, 1=已发送';
|
||||
|
||||
CREATE INDEX idx_trigger_notification ON crypto_tail_strategy_trigger (status, notification_sent);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 尾盘策略最小价差:NONE=不校验, FIXED=固定值, AUTO=历史计算
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
ADD COLUMN min_spread_mode VARCHAR(16) NOT NULL DEFAULT 'NONE' COMMENT '最小价差模式: NONE, FIXED, AUTO',
|
||||
ADD COLUMN min_spread_value DECIMAL(20, 8) NULL COMMENT '最小价差数值(FIXED 时必填;AUTO 时可存计算值)';
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 尾盘策略价差字段重构:支持最小价差/最大价差方向,使用枚举数值存储
|
||||
-- 1. 重命名 min_spread_mode -> spread_mode,并转换为 TINYINT (0=NONE, 1=FIXED, 2=AUTO)
|
||||
-- 2. 重命名 min_spread_value -> spread_value
|
||||
-- 3. 新增 spread_direction 字段,使用 TINYINT (0=MIN, 1=MAX)
|
||||
|
||||
-- 步骤1: 重命名并迁移 spread_mode 数据
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
ADD COLUMN spread_mode_new TINYINT NOT NULL DEFAULT 0 COMMENT '价差模式: 0=NONE, 1=FIXED, 2=AUTO';
|
||||
|
||||
UPDATE crypto_tail_strategy
|
||||
SET spread_mode_new = CASE
|
||||
WHEN min_spread_mode = 'NONE' THEN 0
|
||||
WHEN min_spread_mode = 'FIXED' THEN 1
|
||||
WHEN min_spread_mode = 'AUTO' THEN 2
|
||||
ELSE 0
|
||||
END;
|
||||
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
DROP COLUMN min_spread_mode,
|
||||
CHANGE COLUMN spread_mode_new spread_mode TINYINT NOT NULL DEFAULT 0 COMMENT '价差模式: 0=NONE, 1=FIXED, 2=AUTO';
|
||||
|
||||
-- 步骤2: 重命名 spread_value
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
CHANGE COLUMN min_spread_value spread_value DECIMAL(20, 8) NULL COMMENT '价差数值(FIXED 时必填;AUTO 时可存计算值)';
|
||||
|
||||
-- 步骤3: 新增 spread_direction 字段
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
ADD COLUMN spread_direction TINYINT NOT NULL DEFAULT 0 COMMENT '价差方向: 0=MIN(价差>=配置值触发), 1=MAX(价差<=配置值触发)';
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- 添加触发类型字段到加密价差策略触发记录表
|
||||
-- AUTO: 自动下单触发
|
||||
-- MANUAL: 手动下单触发
|
||||
ALTER TABLE crypto_tail_strategy_trigger
|
||||
ADD COLUMN trigger_type VARCHAR(20) DEFAULT 'AUTO' COMMENT '触发类型:AUTO(自动)或 MANUAL(手动)';
|
||||
@@ -0,0 +1,97 @@
|
||||
-- 消息模板表
|
||||
CREATE TABLE notification_templates (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_type VARCHAR(50) NOT NULL COMMENT '模板类型',
|
||||
template_content TEXT NOT NULL COMMENT '模板内容,支持 {{variable}} 变量',
|
||||
is_default TINYINT(1) DEFAULT 0 COMMENT '是否使用默认模板(0=自定义,1=默认)',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_template_type (template_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息推送模板';
|
||||
|
||||
-- 插入默认模板
|
||||
INSERT INTO notification_templates (template_type, template_content, is_default, created_at, updated_at) VALUES
|
||||
('ORDER_SUCCESS', '🚀 <b>订单创建成功</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 订单ID: <code>{{order_id}}</code>
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
|
||||
|
||||
('ORDER_FAILED', '❌ <b>订单创建失败</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⚠️ <b>错误信息:</b>
|
||||
<code>{{error_message}}</code>
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
|
||||
|
||||
('ORDER_FILTERED', '🚫 <b>订单被过滤</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⚠️ <b>过滤类型:</b> <code>{{filter_type}}</code>
|
||||
|
||||
📝 <b>过滤原因:</b>
|
||||
<code>{{filter_reason}}</code>
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
|
||||
|
||||
('CRYPTO_TAIL_SUCCESS', '🚀 <b>加密价差策略下单成功</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 订单ID: <code>{{order_id}}</code>
|
||||
• 策略: {{strategy_name}}
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
|
||||
|
||||
('REDEEM_SUCCESS', '💸 <b>仓位赎回成功</b>
|
||||
|
||||
📊 <b>赎回信息:</b>
|
||||
• 账户: {{account_name}}
|
||||
• 交易哈希: <code>{{transaction_hash}}</code>
|
||||
• 赎回总价值: <code>{{total_value}}</code> USDC
|
||||
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
|
||||
|
||||
('REDEEM_NO_RETURN', '📋 <b>仓位已结算(无收益)</b>
|
||||
|
||||
📊 <b>结算信息:</b>
|
||||
<i>市场已结算,您的预测未命中,赎回价值为 0。</i>
|
||||
|
||||
• 账户: {{account_name}}
|
||||
• 交易哈希: <code>{{transaction_hash}}</code>
|
||||
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000);
|
||||
@@ -13,9 +13,20 @@ notification.order.quantity=Quantity
|
||||
notification.order.amount=Amount
|
||||
notification.order.account=Account
|
||||
notification.order.time=Time
|
||||
notification.order.available_balance=Available Balance
|
||||
notification.order.error_info=Error Information
|
||||
notification.order.unknown_account=Unknown Account
|
||||
notification.order.calculate_failed=Calculation Failed
|
||||
notification.order.filtered=Order Filtered
|
||||
notification.order.filter_reason=Filter Reason
|
||||
notification.order.filter_type=Filter Type
|
||||
notification.filter.type.order_depth=Insufficient Order Depth
|
||||
notification.filter.type.spread=Spread Too Large
|
||||
notification.filter.type.orderbook_depth=Insufficient Orderbook Depth
|
||||
notification.filter.type.price_validity=Invalid Price
|
||||
notification.filter.type.market_status=Market Not Tradable
|
||||
notification.tail.order.success=Crypto spread strategy order success
|
||||
notification.tail.strategy=Strategy
|
||||
notification.redeem.success=Position Redeemed Successfully
|
||||
notification.redeem.info=Redeem Information
|
||||
notification.redeem.transaction_hash=Transaction Hash
|
||||
@@ -24,6 +35,13 @@ notification.redeem.position_count=Position Count
|
||||
notification.redeem.positions=Redeemed Positions
|
||||
notification.redeem.account=Account
|
||||
notification.redeem.time=Time
|
||||
notification.redeem.available_balance=Available Balance
|
||||
|
||||
# Position Settled (No Return)
|
||||
notification.redeem.no_return.title=Position Settled (No Return)
|
||||
notification.redeem.no_return.info=Settlement Information
|
||||
notification.redeem.no_return.message=Market settled. Your prediction was incorrect. Redemption value is 0.
|
||||
notification.redeem.no_return.positions=Settled Positions
|
||||
|
||||
# Auto Redeem related notifications
|
||||
notification.auto_redeem.disabled.title=Auto Redeem Disabled
|
||||
@@ -274,6 +292,18 @@ error.server.backtest_historical_data_fetch_failed=Failed to fetch historical da
|
||||
error.server.backtest_stop_failed=Failed to stop backtest task
|
||||
error.server.backtest_retry_failed=Failed to retry backtest task
|
||||
error.server.backtest_rerun_failed=Failed to re-run backtest with same config
|
||||
|
||||
# Crypto spread strategy
|
||||
error.crypto_tail_strategy_not_found=Crypto spread strategy not found
|
||||
error.crypto_tail_strategy_window_invalid=Window start must not be greater than window end
|
||||
error.crypto_tail_strategy_window_exceed=Time window must not exceed period length
|
||||
error.crypto_tail_strategy_interval_invalid=Interval must be 300 or 900 seconds
|
||||
error.crypto_tail_strategy_amount_mode_invalid=Amount mode must be RATIO or FIXED
|
||||
error.server.crypto_tail_strategy_create_failed=Failed to create crypto spread strategy
|
||||
error.server.crypto_tail_strategy_update_failed=Failed to update crypto spread strategy
|
||||
error.server.crypto_tail_strategy_delete_failed=Failed to delete crypto spread strategy
|
||||
error.server.crypto_tail_strategy_list_fetch_failed=Failed to fetch crypto spread strategy list
|
||||
error.server.crypto_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records
|
||||
# Backtest Management
|
||||
backtest.title=Backtest Management
|
||||
backtest.create_task=Create Backtest
|
||||
|
||||
@@ -13,9 +13,20 @@ notification.order.quantity=数量
|
||||
notification.order.amount=金额
|
||||
notification.order.account=账户
|
||||
notification.order.time=时间
|
||||
notification.order.available_balance=可用余额
|
||||
notification.order.error_info=错误信息
|
||||
notification.order.unknown_account=未知账户
|
||||
notification.order.calculate_failed=计算失败
|
||||
notification.order.filtered=订单被过滤
|
||||
notification.order.filter_reason=过滤原因
|
||||
notification.order.filter_type=过滤类型
|
||||
notification.filter.type.order_depth=订单深度不足
|
||||
notification.filter.type.spread=价差过大
|
||||
notification.filter.type.orderbook_depth=订单簿深度不足
|
||||
notification.filter.type.price_validity=价格不合理
|
||||
notification.filter.type.market_status=市场状态不可交易
|
||||
notification.tail.order.success=加密价差策略下单成功
|
||||
notification.tail.strategy=策略
|
||||
notification.redeem.success=仓位赎回成功
|
||||
notification.redeem.info=赎回信息
|
||||
notification.redeem.transaction_hash=交易哈希
|
||||
@@ -24,6 +35,13 @@ notification.redeem.position_count=仓位数量
|
||||
notification.redeem.positions=赎回仓位
|
||||
notification.redeem.account=账户
|
||||
notification.redeem.time=时间
|
||||
notification.redeem.available_balance=可用余额
|
||||
|
||||
# 仓位已结算(无收益)
|
||||
notification.redeem.no_return.title=仓位已结算(无收益)
|
||||
notification.redeem.no_return.info=结算信息
|
||||
notification.redeem.no_return.message=市场已结算,您的预测未命中,赎回价值为 0。
|
||||
notification.redeem.no_return.positions=结算仓位
|
||||
|
||||
# 自动赎回相关通知
|
||||
notification.auto_redeem.disabled.title=自动赎回未开启
|
||||
@@ -274,6 +292,18 @@ error.server.backtest_historical_data_fetch_failed=历史数据获取失败
|
||||
error.server.backtest_stop_failed=停止回测任务失败
|
||||
error.server.backtest_retry_failed=重试回测任务失败
|
||||
error.server.backtest_rerun_failed=按配置重新测试失败
|
||||
|
||||
# 加密价差策略
|
||||
error.crypto_tail_strategy_not_found=加密价差策略不存在
|
||||
error.crypto_tail_strategy_window_invalid=时间区间开始不能大于结束
|
||||
error.crypto_tail_strategy_window_exceed=时间区间不能超过周期长度
|
||||
error.crypto_tail_strategy_interval_invalid=周期仅支持 300 或 900 秒
|
||||
error.crypto_tail_strategy_amount_mode_invalid=投入方式仅支持 RATIO 或 FIXED
|
||||
error.server.crypto_tail_strategy_create_failed=创建加密价差策略失败
|
||||
error.server.crypto_tail_strategy_update_failed=更新加密价差策略失败
|
||||
error.server.crypto_tail_strategy_delete_failed=删除加密价差策略失败
|
||||
error.server.crypto_tail_strategy_list_fetch_failed=查询加密价差策略列表失败
|
||||
error.server.crypto_tail_strategy_triggers_fetch_failed=查询触发记录失败
|
||||
# 回测管理
|
||||
backtest.title=回测管理
|
||||
backtest.create_task=新增回测
|
||||
|
||||
@@ -13,9 +13,20 @@ notification.order.quantity=數量
|
||||
notification.order.amount=金額
|
||||
notification.order.account=賬戶
|
||||
notification.order.time=時間
|
||||
notification.order.available_balance=可用餘額
|
||||
notification.order.error_info=錯誤信息
|
||||
notification.order.unknown_account=未知賬戶
|
||||
notification.order.calculate_failed=計算失敗
|
||||
notification.order.filtered=訂單被過濾
|
||||
notification.order.filter_reason=過濾原因
|
||||
notification.order.filter_type=過濾類型
|
||||
notification.filter.type.order_depth=訂單深度不足
|
||||
notification.filter.type.spread=價差過大
|
||||
notification.filter.type.orderbook_depth=訂單簿深度不足
|
||||
notification.filter.type.price_validity=價格不合理
|
||||
notification.filter.type.market_status=市場狀態不可交易
|
||||
notification.tail.order.success=加密價差策略下單成功
|
||||
notification.tail.strategy=策略
|
||||
notification.redeem.success=倉位贖回成功
|
||||
notification.redeem.info=贖回信息
|
||||
notification.redeem.transaction_hash=交易哈希
|
||||
@@ -24,6 +35,13 @@ notification.redeem.position_count=倉位數量
|
||||
notification.redeem.positions=贖回倉位
|
||||
notification.redeem.account=賬戶
|
||||
notification.redeem.time=時間
|
||||
notification.redeem.available_balance=可用餘額
|
||||
|
||||
# 倉位已結算(無收益)
|
||||
notification.redeem.no_return.title=倉位已結算(無收益)
|
||||
notification.redeem.no_return.info=結算信息
|
||||
notification.redeem.no_return.message=市場已結算,您的預測未命中,贖回價值為 0。
|
||||
notification.redeem.no_return.positions=結算倉位
|
||||
|
||||
# 自動贖回相關通知
|
||||
notification.auto_redeem.disabled.title=自動贖回未開啟
|
||||
@@ -274,6 +292,18 @@ error.server.backtest_historical_data_fetch_failed=歷史數據獲取失敗
|
||||
error.server.backtest_stop_failed=停止回測任務失敗
|
||||
error.server.backtest_retry_failed=重試回測任務失敗
|
||||
error.server.backtest_rerun_failed=依配置重新測試失敗
|
||||
|
||||
# 加密價差策略
|
||||
error.crypto_tail_strategy_not_found=加密價差策略不存在
|
||||
error.crypto_tail_strategy_window_invalid=時間區間開始不能大於結束
|
||||
error.crypto_tail_strategy_window_exceed=時間區間不能超過週期長度
|
||||
error.crypto_tail_strategy_interval_invalid=週期僅支援 300 或 900 秒
|
||||
error.crypto_tail_strategy_amount_mode_invalid=投入方式僅支援 RATIO 或 FIXED
|
||||
error.server.crypto_tail_strategy_create_failed=創建加密價差策略失敗
|
||||
error.server.crypto_tail_strategy_update_failed=更新加密價差策略失敗
|
||||
error.server.crypto_tail_strategy_delete_failed=刪除加密價差策略失敗
|
||||
error.server.crypto_tail_strategy_list_fetch_failed=查詢加密價差策略列表失敗
|
||||
error.server.crypto_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗
|
||||
# 回測管理
|
||||
backtest.title=回測管理
|
||||
backtest.create_task=新增回測
|
||||
|
||||
@@ -1,4 +1,209 @@
|
||||
# PolyHermes 一键部署脚本使用说明
|
||||
# PolyHermes One-Click Deployment Script / PolyHermes 一键部署脚本使用说明
|
||||
|
||||
[English](#english) | [中文](#中文)
|
||||
|
||||
---
|
||||
|
||||
<a name="english"></a>
|
||||
## English
|
||||
|
||||
## ✨ Core Features
|
||||
|
||||
- **Run from any directory** - No need to download source code
|
||||
- **Online images only** - Pull official images from Docker Hub
|
||||
- **Auto-download config** - Download the latest `docker-compose.prod.yml` from GitHub
|
||||
- **Interactive configuration** - User-friendly Q&A style configuration wizard
|
||||
- **Auto-generate secrets** - All sensitive configurations will auto-generate secure random values on Enter
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### One-Click Installation (Recommended)
|
||||
|
||||
**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 a dedicated working directory `~/polyhermes`
|
||||
- 📥 Download the deployment script
|
||||
- ✅ Check Docker environment
|
||||
- ⚙️ Configure all parameters interactively (press Enter for defaults)
|
||||
- 🔐 Auto-generate secure random secrets
|
||||
- 🚀 Download latest images and deploy
|
||||
|
||||
**Or run directly via pipe (without saving file):**
|
||||
```bash
|
||||
# curl method
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash
|
||||
|
||||
# wget method
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -qO- https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash
|
||||
```
|
||||
|
||||
### Method 1: Download and Run Script Directly
|
||||
|
||||
```bash
|
||||
# Download script
|
||||
curl -O https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh
|
||||
|
||||
# Add execute permission
|
||||
chmod +x deploy-interactive.sh
|
||||
|
||||
# Run
|
||||
./deploy-interactive.sh
|
||||
```
|
||||
|
||||
### Method 2: Run in Project Directory
|
||||
|
||||
```bash
|
||||
git clone https://github.com/WrBug/PolyHermes.git
|
||||
cd PolyHermes
|
||||
./deploy-interactive.sh
|
||||
```
|
||||
|
||||
## 📝 Usage Flow
|
||||
|
||||
After running the script, you will be guided through the following steps:
|
||||
|
||||
```
|
||||
Step 1: Environment Check → Check Docker/Docker Compose
|
||||
Step 2: Configuration → Interactive input (press Enter for defaults)
|
||||
Step 3: Get Deploy Config → Download docker-compose.prod.yml from GitHub
|
||||
Step 4: Generate Env File → Auto-generate .env
|
||||
Step 5: Pull Docker Images → Pull latest images from Docker Hub
|
||||
Step 6: Deploy Services → Start containers
|
||||
Step 7: Health Check → Verify services are running properly
|
||||
```
|
||||
|
||||
## ⚡ Simplest Usage
|
||||
|
||||
**Press Enter for all configuration items to use default values**, the script will automatically:
|
||||
- Use port 80 (application) and 3307 (MySQL)
|
||||
- Generate 32-character database password
|
||||
- Generate 128-character JWT secret
|
||||
- Generate 64-character admin reset key
|
||||
- Generate 64-character encryption key
|
||||
- Configure reasonable log levels
|
||||
|
||||
### Interactive Example
|
||||
|
||||
The script will prompt you for configuration one by one, **press Enter to skip and use default values**:
|
||||
|
||||
```
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Step 2: Configuration
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
💡 All configurations are optional, press Enter to use default or auto-generated values
|
||||
|
||||
⚠ Secret config: Press Enter to auto-generate secure random secrets
|
||||
⚠ Other config: Press Enter to use default values in parentheses
|
||||
|
||||
【Basic Configuration】
|
||||
Will configure: Server port, MySQL port, Timezone
|
||||
➤ Server port [Default: 80]: ⏎
|
||||
➤ MySQL port (external access) [Default: 3307]: ⏎
|
||||
➤ Timezone [Default: Asia/Shanghai]: ⏎
|
||||
|
||||
【Database Configuration】
|
||||
Will configure: Database username, Database password
|
||||
➤ Database username [Default: root]: ⏎
|
||||
➤ Database password [Enter to auto-generate]: ⏎
|
||||
[✓] Database password auto-generated (32 characters)
|
||||
|
||||
【Security Configuration】
|
||||
Will configure: JWT secret, Admin password reset key, Data encryption key
|
||||
➤ JWT secret [Enter to auto-generate]: ⏎
|
||||
[✓] JWT secret auto-generated (128 characters)
|
||||
➤ Admin password reset key [Enter to auto-generate]: ⏎
|
||||
[✓] Admin reset key auto-generated (64 characters)
|
||||
➤ Encryption key (for API Key encryption) [Enter to auto-generate]: ⏎
|
||||
[✓] Encryption key auto-generated (64 characters)
|
||||
|
||||
【Log Configuration】
|
||||
Will configure: Root log level, Application log level
|
||||
Available levels: TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
➤ Root log level (third-party libs) [Default: WARN]: ⏎
|
||||
➤ Application log level [Default: INFO]: ⏎
|
||||
|
||||
【Other Configuration】
|
||||
Will configure: Runtime environment, Auto-update policy, GitHub repo
|
||||
➤ Spring Profile [Default: prod]: ⏎
|
||||
➤ Allow prerelease updates (true/false) [Default: false]: ⏎
|
||||
➤ GitHub repository [Default: WrBug/PolyHermes]: ⏎
|
||||
```
|
||||
|
||||
## 🔧 Files Generated by Script
|
||||
|
||||
After running, the script will generate in the current directory:
|
||||
|
||||
1. **docker-compose.prod.yml** - Docker Compose config downloaded from GitHub (always latest)
|
||||
2. **.env** - Environment variables file auto-generated based on your configuration
|
||||
|
||||
These two files contain all the configuration needed to run PolyHermes.
|
||||
|
||||
## 🌐 Post-Deployment Management
|
||||
|
||||
### Quick Update (Recommended)
|
||||
|
||||
If you already have configuration files, running the script again will detect and ask:
|
||||
|
||||
```bash
|
||||
./deploy-interactive.sh
|
||||
```
|
||||
|
||||
```
|
||||
【Existing Configuration Detected】
|
||||
Found existing .env configuration file
|
||||
|
||||
Use existing configuration to update images directly? [Y/n]: ⏎
|
||||
```
|
||||
|
||||
- **Press Enter or input Y**: Use existing config, pull latest images and update
|
||||
- **Input N**: Reconfigure (existing config will be backed up)
|
||||
|
||||
### Manual Management Commands
|
||||
|
||||
```bash
|
||||
# View service status
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Restart services
|
||||
docker compose -f docker-compose.prod.yml restart
|
||||
|
||||
# Stop services
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
|
||||
# Update to latest version
|
||||
docker pull wrbug/polyhermes:latest
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## 🔐 Security Recommendations
|
||||
|
||||
- **Protect .env file**: Contains sensitive information, never commit to version control
|
||||
- **Backup database regularly**: Data is stored in Docker volume `mysql-data`
|
||||
- **Configure HTTPS for production**: Recommend using Nginx or Caddy as reverse proxy
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
|
||||
- [Issue Feedback](https://github.com/WrBug/PolyHermes/issues)
|
||||
- [Full Deployment Documentation](docs/zh/DEPLOYMENT_GUIDE.md)
|
||||
|
||||
---
|
||||
|
||||
<a name="中文"></a>
|
||||
## 中文
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
|
||||
+175
-179
@@ -1,18 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ========================================
|
||||
# PolyHermes Interactive Deploy Script
|
||||
# PolyHermes 交互式一键部署脚本
|
||||
# ========================================
|
||||
# 功能:
|
||||
# - 交互式配置环境变量
|
||||
# - 自动生成安全密钥
|
||||
# - 使用 Docker Hub 线上镜像部署
|
||||
# - 支持配置预检和回滚
|
||||
# Features / 功能:
|
||||
# - Interactive env config / 交互式配置环境变量
|
||||
# - Auto-generate secrets / 自动生成安全密钥
|
||||
# - Deploy via Docker Hub images / 使用 Docker Hub 线上镜像部署
|
||||
# - Config check and rollback / 支持配置预检和回滚
|
||||
# ========================================
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
# Colors / 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
@@ -20,7 +21,14 @@ BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 打印函数
|
||||
# Language: LANG=zh* → prompts in Chinese only; else show "中文 / English"
|
||||
# 语言:LANG 为 zh* 时仅中文,否则显示「中文 / English」
|
||||
USE_ZH_ONLY=false
|
||||
case "${LANG:-}" in
|
||||
zh*) USE_ZH_ONLY=true ;;
|
||||
esac
|
||||
|
||||
# Print functions / 打印函数
|
||||
info() {
|
||||
echo -e "${GREEN}[✓]${NC} $1"
|
||||
}
|
||||
@@ -37,6 +45,17 @@ title() {
|
||||
echo -e "${CYAN}${1}${NC}"
|
||||
}
|
||||
|
||||
# Bilingual: 中文 / English (or Chinese only when LANG=zh*)
|
||||
bilingual() {
|
||||
local zh="$1"
|
||||
local en="$2"
|
||||
if [ "$USE_ZH_ONLY" = true ]; then
|
||||
echo "$zh"
|
||||
else
|
||||
echo "$zh / $en"
|
||||
fi
|
||||
}
|
||||
|
||||
# 生成随机密钥
|
||||
generate_secret() {
|
||||
local length=${1:-32}
|
||||
@@ -47,54 +66,57 @@ generate_secret() {
|
||||
fi
|
||||
}
|
||||
|
||||
# 生成随机端口号(10000-60000之间)
|
||||
# 生成随机端口号(10000-60000之间)/ Generate random port (10000-60000)
|
||||
generate_random_port() {
|
||||
echo $((10000 + RANDOM % 50001))
|
||||
}
|
||||
|
||||
# 读取用户输入(支持默认值)
|
||||
# 读取用户输入(支持默认值)/ Read user input (with default)
|
||||
read_input() {
|
||||
local prompt="$1"
|
||||
local default="$2"
|
||||
local is_secret="$3"
|
||||
local value=""
|
||||
|
||||
# 构建提示信息(不使用颜色,因为 read -p 可能不支持)
|
||||
local prompt_text=""
|
||||
if [ -n "$default" ]; then
|
||||
if [ "$is_secret" = "secret" ]; then
|
||||
prompt_text="${prompt} [回车自动生成]: "
|
||||
if [ "$USE_ZH_ONLY" = true ]; then
|
||||
prompt_text="${prompt} [回车自动生成]: "
|
||||
else
|
||||
prompt_text="${prompt} [Enter to auto-generate]: "
|
||||
fi
|
||||
else
|
||||
prompt_text="${prompt} [默认: ${default}]: "
|
||||
if [ "$USE_ZH_ONLY" = true ]; then
|
||||
prompt_text="${prompt} [默认: ${default}]: "
|
||||
else
|
||||
prompt_text="${prompt} [Default: ${default}]: "
|
||||
fi
|
||||
fi
|
||||
else
|
||||
prompt_text="${prompt}: "
|
||||
fi
|
||||
|
||||
# 使用 read -p 确保提示正确显示
|
||||
read -r -p "$prompt_text" value
|
||||
|
||||
# 如果用户没有输入,使用默认值
|
||||
if [ -z "$value" ]; then
|
||||
if [ "$is_secret" = "secret" ] && [ -z "$default" ]; then
|
||||
# 自动生成密钥
|
||||
case "$prompt" in
|
||||
*JWT*)
|
||||
*JWT*|*jwt*)
|
||||
value=$(generate_secret 64)
|
||||
# 输出到 stderr,避免被捕获到返回值中
|
||||
info "已自动生成 JWT 密钥(128字符)" >&2
|
||||
info "$(bilingual "已自动生成 JWT 密钥(128字符)" "JWT secret auto-generated (128 chars)")" >&2
|
||||
;;
|
||||
*管理员*|*ADMIN*)
|
||||
*管理员*|*ADMIN*|*admin*|*reset*|*Reset*)
|
||||
value=$(generate_secret 32)
|
||||
info "已自动生成管理员重置密钥(64字符)" >&2
|
||||
info "$(bilingual "已自动生成管理员重置密钥(64字符)" "Admin reset key auto-generated (64 chars)")" >&2
|
||||
;;
|
||||
*加密*|*CRYPTO*)
|
||||
*加密*|*CRYPTO*|*crypto*|*Encryption*)
|
||||
value=$(generate_secret 32)
|
||||
info "已自动生成加密密钥(64字符)" >&2
|
||||
info "$(bilingual "已自动生成加密密钥(64字符)" "Encryption key auto-generated (64 chars)")" >&2
|
||||
;;
|
||||
*数据库密码*|*DB_PASSWORD*)
|
||||
*数据库密码*|*DB_PASSWORD*|*database*|*Database*)
|
||||
value=$(generate_secret 16)
|
||||
info "已自动生成数据库密码(32字符)" >&2
|
||||
info "$(bilingual "已自动生成数据库密码(32字符)" "Database password auto-generated (32 chars)")" >&2
|
||||
;;
|
||||
*)
|
||||
value="$default"
|
||||
@@ -108,126 +130,115 @@ read_input() {
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
# 检查 Docker 环境
|
||||
# 检查 Docker 环境 / Check Docker environment
|
||||
check_docker() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 1: 环境检查"
|
||||
title " $(bilingual "步骤 1: 环境检查" "Step 1: Environment Check")"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# 检查 Docker
|
||||
if ! command -v docker &> /dev/null; then
|
||||
error "Docker 未安装"
|
||||
error "$(bilingual "Docker 未安装" "Docker is not installed")"
|
||||
echo ""
|
||||
info "请先安装 Docker:"
|
||||
info "$(bilingual "请先安装 Docker:" "Please install Docker first:")"
|
||||
info " macOS: brew install docker"
|
||||
info " Ubuntu/Debian: apt-get install docker.io"
|
||||
info " CentOS/RHEL: yum install docker"
|
||||
exit 1
|
||||
fi
|
||||
info "Docker 已安装: $(docker --version | head -1)"
|
||||
info "$(bilingual "Docker 已安装" "Docker installed"): $(docker --version | head -1)"
|
||||
|
||||
# 检查 Docker Compose
|
||||
if docker compose version &> /dev/null 2>&1; then
|
||||
info "Docker Compose 已安装: $(docker compose version)"
|
||||
info "$(bilingual "Docker Compose 已安装" "Docker Compose installed"): $(docker compose version)"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
info "Docker Compose 已安装: $(docker-compose --version)"
|
||||
info "$(bilingual "Docker Compose 已安装" "Docker Compose installed"): $(docker-compose --version)"
|
||||
else
|
||||
error "Docker Compose 未安装"
|
||||
error "$(bilingual "Docker Compose 未安装" "Docker Compose is not installed")"
|
||||
echo ""
|
||||
info "请先安装 Docker Compose:"
|
||||
info "$(bilingual "请先安装 Docker Compose:" "Please install Docker Compose:")"
|
||||
info " https://docs.docker.com/compose/install/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Docker 守护进程
|
||||
if ! docker info &> /dev/null; then
|
||||
error "Docker 守护进程未运行"
|
||||
info "请启动 Docker 服务:"
|
||||
info " macOS: 打开 Docker Desktop"
|
||||
error "$(bilingual "Docker 守护进程未运行" "Docker daemon is not running")"
|
||||
info "$(bilingual "请启动 Docker 服务:" "Please start Docker:")"
|
||||
info " $(bilingual "macOS: 打开 Docker Desktop" "macOS: Open Docker Desktop")"
|
||||
info " Linux: systemctl start docker"
|
||||
exit 1
|
||||
fi
|
||||
info "Docker 守护进程运行正常"
|
||||
info "$(bilingual "Docker 守护进程运行正常" "Docker daemon is running")"
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 交互式配置收集
|
||||
# 交互式配置收集 / Interactive configuration
|
||||
collect_configuration() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 2: 配置收集"
|
||||
title " $(bilingual "步骤 2: 配置收集" "Step 2: Configuration")"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
info "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成"
|
||||
info "$(bilingual "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成" "💡 All options are optional, press Enter for default or auto-generated values")"
|
||||
echo ""
|
||||
warn "密钥配置:回车将自动生成安全的随机密钥"
|
||||
warn "其他配置:回车将使用括号中的默认值"
|
||||
warn "$(bilingual "密钥配置:回车将自动生成安全的随机密钥" "Secrets: Enter to auto-generate secure random keys")"
|
||||
warn "$(bilingual "其他配置:回车将使用括号中的默认值" "Other: Enter to use default value in brackets")"
|
||||
echo ""
|
||||
|
||||
# 基础配置
|
||||
title "【基础配置】"
|
||||
echo -e "${CYAN}将配置:服务器端口、MySQL端口、时区${NC}"
|
||||
# 生成随机端口作为默认值
|
||||
title "$(bilingual "【基础配置】" "【Basic】")"
|
||||
echo -e "${CYAN}$(bilingual "将配置:服务器端口、MySQL端口、时区" "Server port, MySQL port, Timezone")${NC}"
|
||||
DEFAULT_PORT=$(generate_random_port)
|
||||
SERVER_PORT=$(read_input "➤ 服务器端口" "$DEFAULT_PORT")
|
||||
MYSQL_PORT=$(read_input "➤ MySQL 端口(外部访问)" "3307")
|
||||
TZ=$(read_input "➤ 时区" "Asia/Shanghai")
|
||||
SERVER_PORT=$(read_input "$(bilingual "➤ 服务器端口" "➤ Server port")" "$DEFAULT_PORT")
|
||||
MYSQL_PORT=$(read_input "$(bilingual "➤ MySQL 端口(外部访问)" "➤ MySQL port (external)")" "3307")
|
||||
TZ=$(read_input "$(bilingual "➤ 时区" "➤ Timezone")" "Asia/Shanghai")
|
||||
echo ""
|
||||
|
||||
# 数据库配置
|
||||
title "【数据库配置】"
|
||||
echo -e "${CYAN}将配置:数据库用户名、数据库密码${NC}"
|
||||
echo -e "${YELLOW}💡 提示:密码留空将自动生成 32 字符的安全随机密码${NC}"
|
||||
DB_USERNAME=$(read_input "➤ 数据库用户名" "root")
|
||||
DB_PASSWORD=$(read_input "➤ 数据库密码" "" "secret")
|
||||
title "$(bilingual "【数据库配置】" "【Database】")"
|
||||
echo -e "${CYAN}$(bilingual "将配置:数据库用户名、数据库密码" "Database username, password")${NC}"
|
||||
echo -e "${YELLOW}$(bilingual "💡 提示:密码留空将自动生成 32 字符的安全随机密码" "💡 Leave password empty to auto-generate 32-char password")${NC}"
|
||||
DB_USERNAME=$(read_input "$(bilingual "➤ 数据库用户名" "➤ Database username")" "root")
|
||||
DB_PASSWORD=$(read_input "$(bilingual "➤ 数据库密码" "➤ Database password")" "" "secret")
|
||||
echo ""
|
||||
|
||||
# 安全配置
|
||||
title "【安全配置】"
|
||||
echo -e "${CYAN}将配置:JWT密钥、管理员密码重置密钥、数据加密密钥${NC}"
|
||||
echo -e "${YELLOW}💡 提示:留空将自动生成高强度随机密钥(推荐)${NC}"
|
||||
JWT_SECRET=$(read_input "➤ JWT 密钥" "" "secret")
|
||||
ADMIN_RESET_PASSWORD_KEY=$(read_input "➤ 管理员密码重置密钥" "" "secret")
|
||||
CRYPTO_SECRET_KEY=$(read_input "➤ 加密密钥(用于加密 API Key)" "" "secret")
|
||||
title "$(bilingual "【安全配置】" "【Security】")"
|
||||
echo -e "${CYAN}$(bilingual "将配置:JWT密钥、管理员密码重置密钥、数据加密密钥" "JWT secret, Admin reset key, Encryption key")${NC}"
|
||||
echo -e "${YELLOW}$(bilingual "💡 提示:留空将自动生成高强度随机密钥(推荐)" "💡 Leave empty to auto-generate strong keys (recommended)")${NC}"
|
||||
JWT_SECRET=$(read_input "$(bilingual "➤ JWT 密钥" "➤ JWT secret")" "" "secret")
|
||||
ADMIN_RESET_PASSWORD_KEY=$(read_input "$(bilingual "➤ 管理员密码重置密钥" "➤ Admin password reset key")" "" "secret")
|
||||
CRYPTO_SECRET_KEY=$(read_input "$(bilingual "➤ 加密密钥(用于加密 API Key)" "➤ Encryption key (for API Key)")" "" "secret")
|
||||
echo ""
|
||||
|
||||
# 日志配置
|
||||
title "【日志配置】"
|
||||
echo -e "${CYAN}将配置:Root日志级别、应用日志级别${NC}"
|
||||
echo -e "${YELLOW}可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF${NC}"
|
||||
LOG_LEVEL_ROOT=$(read_input "➤ Root 日志级别(第三方库)" "WARN")
|
||||
LOG_LEVEL_APP=$(read_input "➤ 应用日志级别" "INFO")
|
||||
title "$(bilingual "【日志配置】" "【Logging】")"
|
||||
echo -e "${CYAN}$(bilingual "将配置:Root日志级别、应用日志级别" "Root log level, App log level")${NC}"
|
||||
echo -e "${YELLOW}$(bilingual "可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF" "Levels: TRACE, DEBUG, INFO, WARN, ERROR, OFF")${NC}"
|
||||
LOG_LEVEL_ROOT=$(read_input "$(bilingual "➤ Root 日志级别(第三方库)" "➤ Root log level (3rd party)")" "WARN")
|
||||
LOG_LEVEL_APP=$(read_input "$(bilingual "➤ 应用日志级别" "➤ App log level")" "INFO")
|
||||
echo ""
|
||||
|
||||
# 自动设置不需要用户输入的配置
|
||||
SPRING_PROFILES_ACTIVE="prod"
|
||||
ALLOW_PRERELEASE="false"
|
||||
GITHUB_REPO="WrBug/PolyHermes"
|
||||
}
|
||||
|
||||
# 下载 docker-compose.prod.yml(如果不存在)
|
||||
# 下载 docker-compose.prod.yml(如果不存在)/ Download docker-compose.prod.yml if missing
|
||||
download_docker_compose_file() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 3: 获取部署配置"
|
||||
title " $(bilingual "步骤 3: 获取部署配置" "Step 3: Get Deploy Config")"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
if [ -f "docker-compose.prod.yml" ]; then
|
||||
info "检测到现有 docker-compose.prod.yml,跳过下载"
|
||||
info "$(bilingual "检测到现有 docker-compose.prod.yml,跳过下载" "Existing docker-compose.prod.yml found, skip download")"
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "正在从 GitHub 下载 docker-compose.prod.yml..."
|
||||
info "$(bilingual "正在从 GitHub 下载 docker-compose.prod.yml..." "Downloading docker-compose.prod.yml from GitHub...")"
|
||||
|
||||
# GitHub raw 文件链接
|
||||
local compose_url="https://raw.githubusercontent.com/WrBug/PolyHermes/main/docker-compose.prod.yml"
|
||||
|
||||
# 尝试下载
|
||||
if curl -fsSL "$compose_url" -o docker-compose.prod.yml; then
|
||||
info "docker-compose.prod.yml 下载成功"
|
||||
info "$(bilingual "docker-compose.prod.yml 下载成功" "docker-compose.prod.yml downloaded")"
|
||||
else
|
||||
error "docker-compose.prod.yml 下载失败"
|
||||
warn "请检查网络连接或手动下载:"
|
||||
error "$(bilingual "docker-compose.prod.yml 下载失败" "Failed to download docker-compose.prod.yml")"
|
||||
warn "$(bilingual "请检查网络连接或手动下载:" "Check network or download manually:")"
|
||||
warn " $compose_url"
|
||||
exit 1
|
||||
fi
|
||||
@@ -235,28 +246,26 @@ download_docker_compose_file() {
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 生成 .env 文件
|
||||
# 生成 .env 文件 / Generate .env file
|
||||
generate_env_file() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 4: 生成环境变量文件"
|
||||
title " $(bilingual "步骤 4: 生成环境变量文件" "Step 4: Generate .env")"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# 备份现有 .env 文件
|
||||
if [ -f ".env" ]; then
|
||||
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp .env "$BACKUP_FILE"
|
||||
warn "已备份现有配置文件到: $BACKUP_FILE"
|
||||
warn "$(bilingual "已备份现有配置文件到" "Backed up existing config to"): $BACKUP_FILE"
|
||||
fi
|
||||
|
||||
# 生成新的 .env 文件
|
||||
cat > .env <<EOF
|
||||
# ========================================
|
||||
# PolyHermes 生产环境配置
|
||||
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
# PolyHermes Production Config / 生产环境配置
|
||||
# Generated / 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
# ========================================
|
||||
|
||||
# ============================================
|
||||
# 基础配置
|
||||
# Basic / 基础配置
|
||||
# ============================================
|
||||
TZ=${TZ}
|
||||
SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE}
|
||||
@@ -264,112 +273,107 @@ SERVER_PORT=${SERVER_PORT}
|
||||
MYSQL_PORT=${MYSQL_PORT}
|
||||
|
||||
# ============================================
|
||||
# 数据库配置
|
||||
# Database / 数据库配置
|
||||
# ============================================
|
||||
DB_URL=jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
|
||||
DB_USERNAME=${DB_USERNAME}
|
||||
DB_PASSWORD=${DB_PASSWORD}
|
||||
|
||||
# ============================================
|
||||
# 安全配置(请妥善保管)
|
||||
# Security (keep safe) / 安全配置(请妥善保管)
|
||||
# ============================================
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY}
|
||||
CRYPTO_SECRET_KEY=${CRYPTO_SECRET_KEY}
|
||||
|
||||
# ============================================
|
||||
# 日志配置
|
||||
# Logging / 日志配置
|
||||
# ============================================
|
||||
LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT}
|
||||
LOG_LEVEL_APP=${LOG_LEVEL_APP}
|
||||
|
||||
# ============================================
|
||||
# 其他配置
|
||||
# Other / 其他配置
|
||||
# ============================================
|
||||
ALLOW_PRERELEASE=${ALLOW_PRERELEASE}
|
||||
GITHUB_REPO=${GITHUB_REPO}
|
||||
EOF
|
||||
|
||||
info "配置文件已生成: .env"
|
||||
info "$(bilingual "配置文件已生成" "Config file generated"): .env"
|
||||
echo ""
|
||||
|
||||
# 显示配置摘要
|
||||
title "【配置摘要】"
|
||||
echo " 服务器端口: ${SERVER_PORT}"
|
||||
echo " MySQL 端口: ${MYSQL_PORT}"
|
||||
echo " 时区: ${TZ}"
|
||||
echo " 数据库用户: ${DB_USERNAME}"
|
||||
echo " 数据库密码: ${DB_PASSWORD:0:8}... (已隐藏)"
|
||||
echo " JWT 密钥: ${JWT_SECRET:0:16}... (已隐藏)"
|
||||
echo " 管理员重置密钥: ${ADMIN_RESET_PASSWORD_KEY:0:16}... (已隐藏)"
|
||||
echo " 加密密钥: ${CRYPTO_SECRET_KEY:0:16}... (已隐藏)"
|
||||
echo " 日志级别: Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}"
|
||||
title "$(bilingual "【配置摘要】" "【Config Summary】")"
|
||||
echo " $(bilingual "服务器端口" "Server port"): ${SERVER_PORT}"
|
||||
echo " $(bilingual "MySQL 端口" "MySQL port"): ${MYSQL_PORT}"
|
||||
echo " $(bilingual "时区" "Timezone"): ${TZ}"
|
||||
echo " $(bilingual "数据库用户" "DB user"): ${DB_USERNAME}"
|
||||
echo " $(bilingual "数据库密码" "DB password"): ${DB_PASSWORD:0:8}... $(bilingual "(已隐藏)" "(hidden)")"
|
||||
echo " $(bilingual "JWT 密钥" "JWT secret"): ${JWT_SECRET:0:16}... $(bilingual "(已隐藏)" "(hidden)")"
|
||||
echo " $(bilingual "管理员重置密钥" "Admin reset key"): ${ADMIN_RESET_PASSWORD_KEY:0:16}... $(bilingual "(已隐藏)" "(hidden)")"
|
||||
echo " $(bilingual "加密密钥" "Encryption key"): ${CRYPTO_SECRET_KEY:0:16}... $(bilingual "(已隐藏)" "(hidden)")"
|
||||
echo " $(bilingual "日志级别" "Log level"): Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 拉取镜像
|
||||
# 拉取镜像 / Pull images
|
||||
pull_images() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 5: 拉取 Docker 镜像"
|
||||
title " $(bilingual "步骤 5: 拉取 Docker 镜像" "Step 5: Pull Docker Images")"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
info "正在从 Docker Hub 拉取最新镜像..."
|
||||
info "$(bilingual "正在从 Docker Hub 拉取最新镜像..." "Pulling latest images from Docker Hub...")"
|
||||
|
||||
# 拉取应用镜像
|
||||
if docker pull wrbug/polyhermes:latest; then
|
||||
info "应用镜像拉取成功: wrbug/polyhermes:latest"
|
||||
info "$(bilingual "应用镜像拉取成功" "App image pulled"): wrbug/polyhermes:latest"
|
||||
else
|
||||
error "应用镜像拉取失败"
|
||||
warn "可能的原因:"
|
||||
warn " 1. 网络连接问题"
|
||||
warn " 2. Docker Hub 服务异常"
|
||||
warn " 3. 镜像不存在"
|
||||
error "$(bilingual "应用镜像拉取失败" "Failed to pull app image")"
|
||||
warn "$(bilingual "可能的原因:" "Possible reasons:")"
|
||||
warn " 1. $(bilingual "网络连接问题" "Network issue")"
|
||||
warn " 2. $(bilingual "Docker Hub 服务异常" "Docker Hub unavailable")"
|
||||
warn " 3. $(bilingual "镜像不存在" "Image not found")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 拉取 MySQL 镜像
|
||||
if docker pull mysql:8.2; then
|
||||
info "MySQL 镜像拉取成功: mysql:8.2"
|
||||
info "$(bilingual "MySQL 镜像拉取成功" "MySQL image pulled"): mysql:8.2"
|
||||
else
|
||||
warn "MySQL 镜像拉取失败,将在启动时自动下载"
|
||||
warn "$(bilingual "MySQL 镜像拉取失败,将在启动时自动下载" "MySQL pull failed, will download on start")"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 部署服务
|
||||
# 部署服务 / Deploy services
|
||||
deploy_services() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 6: 部署服务"
|
||||
title " $(bilingual "步骤 6: 部署服务" "Step 6: Deploy Services")"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# 停止现有服务
|
||||
if docker compose -f docker-compose.prod.yml ps -q 2>/dev/null | grep -q .; then
|
||||
warn "检测到正在运行的服务,正在停止..."
|
||||
warn "$(bilingual "检测到正在运行的服务,正在停止..." "Stopping existing services...")"
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
info "已停止现有服务"
|
||||
info "$(bilingual "已停止现有服务" "Stopped existing services")"
|
||||
fi
|
||||
|
||||
# 启动服务
|
||||
info "正在启动服务..."
|
||||
info "$(bilingual "正在启动服务..." "Starting services...")"
|
||||
if docker compose -f docker-compose.prod.yml up -d; then
|
||||
info "服务启动成功"
|
||||
info "$(bilingual "服务启动成功" "Services started")"
|
||||
else
|
||||
error "服务启动失败"
|
||||
error "请检查日志: docker compose -f docker-compose.prod.yml logs"
|
||||
error "$(bilingual "服务启动失败" "Failed to start services")"
|
||||
error "$(bilingual "请检查日志" "Check logs"): docker compose -f docker-compose.prod.yml logs"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
# 健康检查 / Health check
|
||||
health_check() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 7: 健康检查"
|
||||
title " $(bilingual "步骤 7: 健康检查" "Step 7: Health Check")"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
info "等待服务启动(最多等待 60 秒)..."
|
||||
info "$(bilingual "等待服务启动(最多等待 60 秒)..." "Waiting for services (up to 60s)...")"
|
||||
|
||||
local max_attempts=12
|
||||
local attempt=0
|
||||
@@ -377,13 +381,11 @@ health_check() {
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
# 检查容器状态
|
||||
if docker compose -f docker-compose.prod.yml ps | grep -q "Up"; then
|
||||
info "容器运行正常"
|
||||
info "$(bilingual "容器运行正常" "Containers are up")"
|
||||
|
||||
# 检查应用是否响应
|
||||
if curl -s -o /dev/null -w "%{http_code}" http://localhost:${SERVER_PORT} | grep -q "200\|302\|401"; then
|
||||
info "应用响应正常"
|
||||
info "$(bilingual "应用响应正常" "App is responding")"
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
@@ -394,79 +396,76 @@ health_check() {
|
||||
done
|
||||
|
||||
echo ""
|
||||
warn "健康检查超时,请手动检查服务状态"
|
||||
warn "查看日志: docker compose -f docker-compose.prod.yml logs -f"
|
||||
warn "$(bilingual "健康检查超时,请手动检查服务状态" "Health check timeout, please check services manually")"
|
||||
warn "$(bilingual "查看日志" "View logs"): docker compose -f docker-compose.prod.yml logs -f"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 显示部署信息
|
||||
# 显示部署信息 / Show deployment info
|
||||
show_deployment_info() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 部署完成!"
|
||||
title " $(bilingual "部署完成!" "Deployment Complete!")"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
info "访问地址: ${GREEN}http://localhost:${SERVER_PORT}${NC}"
|
||||
info "$(bilingual "访问地址" "Access URL"): ${GREEN}http://localhost:${SERVER_PORT}${NC}"
|
||||
echo ""
|
||||
|
||||
title "【常用命令】"
|
||||
echo -e " 查看服务状态: ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}"
|
||||
echo -e " 查看日志: ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}"
|
||||
echo -e " 停止服务: ${CYAN}docker compose -f docker-compose.prod.yml down${NC}"
|
||||
echo -e " 重启服务: ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}"
|
||||
echo -e " 更新镜像: ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}"
|
||||
title "$(bilingual "【常用命令】" "【Common Commands】")"
|
||||
echo -e " $(bilingual "查看服务状态" "Status"): ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}"
|
||||
echo -e " $(bilingual "查看日志" "Logs"): ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}"
|
||||
echo -e " $(bilingual "停止服务" "Stop"): ${CYAN}docker compose -f docker-compose.prod.yml down${NC}"
|
||||
echo -e " $(bilingual "重启服务" "Restart"): ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}"
|
||||
echo -e " $(bilingual "更新镜像" "Update"): ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}"
|
||||
echo ""
|
||||
|
||||
title "【数据库连接信息】"
|
||||
echo -e " 主机: ${CYAN}localhost${NC}"
|
||||
echo -e " 端口: ${CYAN}${MYSQL_PORT}${NC}"
|
||||
echo -e " 数据库: ${CYAN}polyhermes${NC}"
|
||||
echo -e " 用户名: ${CYAN}${DB_USERNAME}${NC}"
|
||||
echo -e " 密码: ${CYAN}${DB_PASSWORD}${NC}"
|
||||
title "$(bilingual "【数据库连接信息】" "【Database Connection】")"
|
||||
echo -e " $(bilingual "主机" "Host"): ${CYAN}localhost${NC}"
|
||||
echo -e " $(bilingual "端口" "Port"): ${CYAN}${MYSQL_PORT}${NC}"
|
||||
echo -e " $(bilingual "数据库" "Database"): ${CYAN}polyhermes${NC}"
|
||||
echo -e " $(bilingual "用户名" "Username"): ${CYAN}${DB_USERNAME}${NC}"
|
||||
echo -e " $(bilingual "密码" "Password"): ${CYAN}${DB_PASSWORD}${NC}"
|
||||
echo ""
|
||||
|
||||
title "【管理员重置密钥】"
|
||||
echo -e " 重置密钥: ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}"
|
||||
echo -e " ${YELLOW}💡 此密钥用于重置管理员密码,请妥善保管${NC}"
|
||||
title "$(bilingual "【管理员重置密钥】" "【Admin Reset Key】")"
|
||||
echo -e " $(bilingual "重置密钥" "Reset key"): ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}"
|
||||
echo -e " ${YELLOW}$(bilingual "💡 此密钥用于重置管理员密码,请妥善保管" "💡 Keep this key safe; it is used to reset admin password")${NC}"
|
||||
echo ""
|
||||
|
||||
warn "重要提示:"
|
||||
warn " 1. 请妥善保管 .env 文件,勿提交到版本控制系统"
|
||||
warn " 2. 定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data)"
|
||||
warn " 3. 生产环境建议配置反向代理(如 Nginx)并启用 HTTPS"
|
||||
warn "$(bilingual "重要提示:" "Important:")"
|
||||
warn " 1. $(bilingual "请妥善保管 .env 文件,勿提交到版本控制系统" "Keep .env secure; do not commit to version control")"
|
||||
warn " 2. $(bilingual "定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data)" "Back up DB regularly (Docker volume: polyhermes_mysql-data)")"
|
||||
warn " 3. $(bilingual "生产环境建议配置反向代理(如 Nginx)并启用 HTTPS" "Use a reverse proxy (e.g. Nginx) and HTTPS in production")"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 主函数
|
||||
# 主函数 / Main
|
||||
main() {
|
||||
clear
|
||||
|
||||
echo ""
|
||||
title "========================================="
|
||||
title " PolyHermes 交互式一键部署脚本 "
|
||||
title " $(bilingual "PolyHermes 交互式一键部署脚本" "PolyHermes Interactive Deploy") "
|
||||
title "========================================="
|
||||
echo ""
|
||||
|
||||
# 执行部署流程
|
||||
check_docker
|
||||
|
||||
# 检查是否已存在 .env 文件
|
||||
if [ -f ".env" ]; then
|
||||
echo ""
|
||||
title "【检测到现有配置】"
|
||||
info "发现已存在的 .env 配置文件"
|
||||
title "$(bilingual "【检测到现有配置】" "【Existing Config Found】")"
|
||||
info "$(bilingual "发现已存在的 .env 配置文件" "Found existing .env file")"
|
||||
echo ""
|
||||
echo -ne "${YELLOW}是否使用现有配置直接更新镜像?[Y/n]: ${NC}"
|
||||
echo -ne "${YELLOW}$(bilingual "是否使用现有配置直接更新镜像?[Y/n]" "Use existing config to update images? [Y/n]"): ${NC}"
|
||||
read -r use_existing
|
||||
use_existing=${use_existing:-Y}
|
||||
|
||||
if [[ "$use_existing" =~ ^[Yy]$ ]]; then
|
||||
info "将使用现有配置,跳过配置步骤"
|
||||
info "$(bilingual "将使用现有配置,跳过配置步骤" "Using existing config, skipping configuration")"
|
||||
echo ""
|
||||
# 从现有 .env 文件读取必要的变量
|
||||
source .env 2>/dev/null || true
|
||||
else
|
||||
warn "将重新配置,现有配置将被备份"
|
||||
warn "$(bilingual "将重新配置,现有配置将被备份" "Will reconfigure; existing config will be backed up")"
|
||||
echo ""
|
||||
collect_configuration
|
||||
fi
|
||||
@@ -476,21 +475,18 @@ main() {
|
||||
|
||||
download_docker_compose_file
|
||||
|
||||
# 只有在重新配置时才生成新的 .env 文件
|
||||
if [[ ! "$use_existing" =~ ^[Yy]$ ]] || [ ! -f ".env" ]; then
|
||||
generate_env_file
|
||||
fi
|
||||
|
||||
# 确认部署
|
||||
echo ""
|
||||
title "【确认部署】"
|
||||
echo -ne "${YELLOW}是否开始部署?[Y/n](回车默认为是): ${NC}"
|
||||
title "$(bilingual "【确认部署】" "【Confirm Deploy】")"
|
||||
echo -ne "${YELLOW}$(bilingual "是否开始部署?[Y/n](回车默认为是)" "Start deployment? [Y/n] (Enter = Yes)"): ${NC}"
|
||||
read -r confirm
|
||||
|
||||
# 默认为 Y,只有明确输入 n/N 才取消
|
||||
confirm=${confirm:-Y}
|
||||
if [[ "$confirm" =~ ^[Nn]$ ]]; then
|
||||
warn "部署已取消"
|
||||
warn "$(bilingual "部署已取消" "Deployment cancelled")"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -500,11 +496,11 @@ main() {
|
||||
health_check
|
||||
show_deployment_info
|
||||
|
||||
info "部署流程已完成!"
|
||||
info "$(bilingual "部署流程已完成!" "Deployment finished!")"
|
||||
}
|
||||
|
||||
# 捕获 Ctrl+C
|
||||
trap 'echo ""; warn "部署已中断"; exit 1' INT
|
||||
# 捕获 Ctrl+C / Handle Ctrl+C
|
||||
trap 'echo ""; warn "$(bilingual "部署已中断" "Deployment interrupted")"; exit 1' INT
|
||||
|
||||
# 运行主函数
|
||||
# 运行主函数 / Run main
|
||||
main "$@"
|
||||
|
||||
@@ -10,6 +10,7 @@ RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
COMPOSE_FILES=(-f docker-compose.yml)
|
||||
|
||||
# 打印信息
|
||||
info() {
|
||||
@@ -24,6 +25,40 @@ error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 从环境变量或 .env 文件读取配置值,避免 source .env 时被特殊字符影响
|
||||
get_config_value() {
|
||||
local key="$1"
|
||||
local value="${!key}"
|
||||
|
||||
if [ -n "$value" ]; then
|
||||
echo "$value"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -f ".env" ]; then
|
||||
grep "^${key}=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\r' || true
|
||||
fi
|
||||
}
|
||||
|
||||
# 根据数据库配置选择 compose 文件
|
||||
configure_compose_files() {
|
||||
local db_url
|
||||
db_url=$(get_config_value "DB_URL")
|
||||
|
||||
COMPOSE_FILES=(-f docker-compose.yml)
|
||||
|
||||
if [[ "$db_url" == *"host.docker.internal"* ]]; then
|
||||
COMPOSE_FILES=(-f docker-compose.host-mysql.yml)
|
||||
info "检测到 DB_URL 指向宿主机 MySQL,将使用 docker-compose.host-mysql.yml"
|
||||
else
|
||||
info "使用默认 docker-compose.yml(包含内置 MySQL 服务)"
|
||||
fi
|
||||
}
|
||||
|
||||
compose() {
|
||||
docker-compose "${COMPOSE_FILES[@]}" "$@"
|
||||
}
|
||||
|
||||
# 检查 Docker 环境
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
@@ -148,6 +183,7 @@ check_security_config() {
|
||||
deploy() {
|
||||
# 检查安全配置
|
||||
check_security_config
|
||||
configure_compose_files
|
||||
|
||||
# 检查是否使用 Docker Hub 镜像
|
||||
USE_DOCKER_HUB="${USE_DOCKER_HUB:-false}"
|
||||
@@ -161,10 +197,15 @@ deploy() {
|
||||
# 注意:这里需要手动修改 docker-compose.yml,或者使用环境变量
|
||||
warn "请确保 docker-compose.yml 中已配置使用 image: wrbug/polyhermes:latest"
|
||||
else
|
||||
# 获取当前分支名作为版本号
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
|
||||
# 如果分支名包含 /,替换为 -(Docker tag 不支持 /)
|
||||
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
|
||||
# 版本号:优先环境变量 DOCKER_VERSION,其次 .env 中的 DOCKER_VERSION,否则用当前分支名
|
||||
if [ -z "${DOCKER_VERSION}" ] && [ -f ".env" ]; then
|
||||
DOCKER_VERSION=$(grep "^DOCKER_VERSION=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\r')
|
||||
fi
|
||||
if [ -z "${DOCKER_VERSION}" ]; then
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
|
||||
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
|
||||
fi
|
||||
export DOCKER_VERSION
|
||||
|
||||
info "构建 Docker 镜像(本地构建,版本号: ${DOCKER_VERSION})..."
|
||||
|
||||
@@ -177,20 +218,20 @@ deploy() {
|
||||
export GIT_TAG=${DOCKER_VERSION}
|
||||
export GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||
|
||||
docker-compose build
|
||||
compose build
|
||||
fi
|
||||
|
||||
info "启动服务..."
|
||||
docker-compose up -d
|
||||
compose up -d
|
||||
|
||||
info "等待服务启动..."
|
||||
sleep 5
|
||||
|
||||
info "检查服务状态..."
|
||||
docker-compose ps
|
||||
compose ps
|
||||
|
||||
info "查看日志: docker-compose logs -f"
|
||||
info "停止服务: docker-compose down"
|
||||
info "查看日志: docker-compose ${COMPOSE_FILES[*]} logs -f"
|
||||
info "停止服务: docker-compose ${COMPOSE_FILES[*]} down"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
@@ -216,9 +257,14 @@ main() {
|
||||
info "访问地址: http://localhost:${SERVER_PORT:-80}"
|
||||
echo ""
|
||||
if [ "$USE_DOCKER_HUB" != "true" ]; then
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
|
||||
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
|
||||
info "提示:本地构建的版本号为当前分支名: ${DOCKER_VERSION}"
|
||||
if [ -z "${DOCKER_VERSION}" ] && [ -f ".env" ]; then
|
||||
DOCKER_VERSION=$(grep "^DOCKER_VERSION=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\r')
|
||||
fi
|
||||
if [ -z "${DOCKER_VERSION}" ]; then
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
|
||||
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
|
||||
fi
|
||||
info "提示:本地构建的版本号: ${DOCKER_VERSION}(可在 .env 或环境变量中设置 DOCKER_VERSION)"
|
||||
info "生产环境推荐使用 Docker Hub 镜像:"
|
||||
info " ./deploy.sh --use-docker-hub"
|
||||
info " 或修改 docker-compose.yml 使用 image: wrbug/polyhermes:latest"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
version: '3.8'
|
||||
|
||||
# 使用宿主机 MySQL 的部署配置。
|
||||
# 适用于 DB_URL 指向 host.docker.internal 的场景,不会启动内置 MySQL 容器。
|
||||
services:
|
||||
app:
|
||||
# 使用 Docker Hub 镜像(推荐生产环境)
|
||||
# 取消注释下面一行,并注释掉 build 部分,即可使用 Docker Hub 的镜像
|
||||
# image: wrbug/polyhermes:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# 本地构建时可以传递版本号参数(自动使用当前分支名)
|
||||
args:
|
||||
VERSION: ${VERSION:-dev}
|
||||
GIT_TAG: ${GIT_TAG:-${VERSION:-dev}}
|
||||
GITHUB_REPO_URL: ${GITHUB_REPO_URL:-https://github.com/WrBug/PolyHermes}
|
||||
container_name: polyhermes
|
||||
ports:
|
||||
- "${SERVER_PORT:-80}:80"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
||||
- DB_URL=${DB_URL:?DB_URL is required when using host MySQL}
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
- DB_PASSWORD=${DB_PASSWORD:-}
|
||||
- SERVER_PORT=8000
|
||||
# ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败
|
||||
# 请在 .env 文件中设置,或通过环境变量传入
|
||||
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||
# 日志级别配置(可选,默认值:root=WARN, app=INFO)
|
||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
|
||||
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
|
||||
# 动态更新配置
|
||||
- ALLOW_PRERELEASE=${ALLOW_PRERELEASE:-false}
|
||||
- GITHUB_REPO=${GITHUB_REPO:-WrBug/PolyHermes}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- polyhermes-network
|
||||
|
||||
networks:
|
||||
polyhermes-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,32 @@
|
||||
# 加密价差策略文档 (Crypto Spread Strategy)
|
||||
|
||||
本目录集中存放与 Polymarket 加密市场加密价差策略相关的文档。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
crypto-tail-strategy/
|
||||
├── README.md # 本说明
|
||||
├── crypto-tail-auto-spread-dynamic-coefficient.md # 自动价差动态系数(中英通用)
|
||||
├── zh/ # 中文文档
|
||||
│ ├── crypto-tail-strategy-user-guide.md # 用户配置指南
|
||||
│ ├── crypto-tail-strategy-ui-spec.md # UI 规格
|
||||
│ ├── crypto-tail-strategy-tasks.md # 任务与验收
|
||||
│ ├── crypto-tail-strategy-flow.md # 流程说明
|
||||
│ ├── crypto-tail-strategy-min-spread-flow.md # 最小/最大价差流程
|
||||
│ └── crypto-tail-strategy-market-data.md # 市场数据与周期
|
||||
└── en/ # 英文文档
|
||||
└── crypto-tail-strategy-user-guide.md # User configuration guide
|
||||
```
|
||||
|
||||
## 文档说明
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| **user-guide** (zh/en) | 面向用户的策略配置指南与 FAQ |
|
||||
| **ui-spec** (zh) | 前端列表、表单、时间窗口、触发记录等 UI 规格 |
|
||||
| **tasks** (zh) | 开发任务与验收项 |
|
||||
| **flow** (zh) | 策略整体流程 |
|
||||
| **min-spread-flow** (zh) | 价差过滤(最小/最大价差)流程 |
|
||||
| **market-data** (zh) | Gamma slug、周期、时间区间、价格判断等市场数据规则 |
|
||||
| **auto-spread-dynamic-coefficient** | 自动价差模式下动态系数计算说明 |
|
||||
@@ -0,0 +1,131 @@
|
||||
# AUTO 最小价差:100%→50% 动态系数方案
|
||||
|
||||
## 现状
|
||||
|
||||
- **BinanceKlineAutoSpreadService**:拉取历史 K 线 → IQR 剔除异常值 → 求平均得到「基础价差」→ **固定 ×0.7** 后缓存。
|
||||
- 预加载(周期开始时):`computeAndCache()` 计算并缓存的是 **已乘 0.7** 的值。
|
||||
- 触发时:`getAutoMinSpread()` 直接返回缓存值,等价于始终用 **70%** 的系数。
|
||||
|
||||
问题:70% 固定,无法随周期内时间变化放宽或收紧。
|
||||
|
||||
---
|
||||
|
||||
## 目标
|
||||
|
||||
1. **预加载提供 100% 数值**:缓存里存「基础价差」(IQR 平均),不再乘 0.7,即预加载 = 100% 基准。
|
||||
2. **系数随区间时间点动态递减**:从 **100%** 线性递减到 **50%**,根据「当前时间在区间内的进度」计算。
|
||||
|
||||
---
|
||||
|
||||
## 方案一:按「触发窗口」进度(推荐)
|
||||
|
||||
**区间**:策略的触发窗口 `[periodStartUnix + windowStartSeconds, periodStartUnix + windowEndSeconds]`。
|
||||
|
||||
- 窗口起始:系数 = **100%**(最严,价差要求最高)。
|
||||
- 窗口内时间越靠后,系数越小;窗口结束:系数 = **50%**(最松,更容易触发)。
|
||||
|
||||
公式(**progress 按毫秒计算**,保证精度):
|
||||
|
||||
```
|
||||
windowStartMs = (periodStartUnix + windowStartSeconds) * 1000
|
||||
windowEndMs = (periodStartUnix + windowEndSeconds) * 1000
|
||||
windowLenMs = windowEndMs - windowStartMs
|
||||
nowMs = System.currentTimeMillis()
|
||||
|
||||
progress = (nowMs - windowStartMs) / windowLenMs
|
||||
progress = clamp(progress, 0, 1)
|
||||
|
||||
// 比例系数 = progress × (100% - 50%),即已「消耗」的系数降幅
|
||||
// 真正系数 = 100% - 比例系数
|
||||
coefficient = 1.0 - progress × (1.0 - 0.5) = 1.0 - 0.5 × progress
|
||||
|
||||
effectiveMinSpread = baseSpread × coefficient
|
||||
```
|
||||
|
||||
**计算示例**(时间区间 14分0秒~15分0秒,窗口 60 秒 = 60000 ms):
|
||||
|
||||
| 时刻 | 进入窗口的毫秒数 | progress(按毫秒) | 比例系数 | 真正系数 |
|
||||
|------------|------------------|--------------------|--------------------|------------|
|
||||
| 14:00 | 0 | 0/60000 = 0% | 0% × 50% = 0% | 100% |
|
||||
| 14:15 | 15000 | 15000/60000 = 25% | 25% × 50% = 12.5% | **87.5%** |
|
||||
| 14:30 | 30000 | 30000/60000 = 50% | 50% × 50% = 25% | 75% |
|
||||
| 15:00 | 60000 | 60000/60000 = 100% | 100% × 50% = 50% | 50% |
|
||||
|
||||
即:在 14分15秒 时,progress = 15000ms / 60000ms = 25%,比例系数 = 12.5%,真正系数 = **87.5%**。实现时统一用毫秒计算 progress,避免秒级舍入误差。
|
||||
|
||||
- 需要策略的 `windowStartSeconds`、`windowEndSeconds` 传入计算处;若窗口长度为 0,可退化为系数 = 1.0 或 0.5(需约定)。
|
||||
|
||||
**优点**:与「加密价差策略只在窗口内触发」一致,时间语义清晰;毫秒级 progress 更精确。
|
||||
**缺点**:`getAutoMinSpread` 需要增加当前时间(毫秒)和窗口参数(或传整个 strategy)。
|
||||
|
||||
---
|
||||
|
||||
## 方案二:按「整周期」进度
|
||||
|
||||
**区间**:整个周期 `[periodStartUnix, periodStartUnix + intervalSeconds]`。**progress 按毫秒计算**。
|
||||
|
||||
```
|
||||
periodStartMs = periodStartUnix * 1000
|
||||
periodEndMs = (periodStartUnix + intervalSeconds) * 1000
|
||||
periodLenMs = intervalSeconds * 1000L
|
||||
nowMs = System.currentTimeMillis()
|
||||
|
||||
progress = (nowMs - periodStartMs) / periodLenMs
|
||||
progress = clamp(progress, 0, 1)
|
||||
|
||||
coefficient = 1.0 - 0.5 * progress
|
||||
effectiveMinSpread = baseSpread × coefficient
|
||||
```
|
||||
|
||||
**优点**:只依赖 `intervalSeconds`、`periodStartUnix`、`nowSeconds`,不依赖窗口配置。
|
||||
**缺点**:若窗口只占周期后半段,周期前半段也会在算系数,语义上不如按窗口精确。
|
||||
|
||||
---
|
||||
|
||||
## 实现要点
|
||||
|
||||
### 1. 缓存 100% 基准值
|
||||
|
||||
- **BinanceKlineAutoSpreadService**:
|
||||
- `computeAndCache()`:缓存 **不乘 0.7** 的 (avgUp, avgDown),即 IQR 平均后的原始值(100% 基准)。
|
||||
- 可保留方法名与入参不变,仅去掉 `autoSpreadCoefficient` 的乘法;或新增 `getBaseSpread()` 语义,内部仍用同一缓存。
|
||||
|
||||
### 2. 动态系数计算位置
|
||||
|
||||
- 系数依赖「当前时间」和「区间定义」,适合在 **触发校验处** 算,而不是在 AutoSpread 服务里写死。
|
||||
- **CryptoTailStrategyExecutionService.passMinSpreadCheck()**:
|
||||
- 当前:`getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex)` 得到已乘系数的值。
|
||||
- 改为:
|
||||
- 取「基础价差」:`getAutoMinSpreadBase(intervalSeconds, periodStartUnix, outcomeIndex)` 或由现有缓存返回 100% 值。
|
||||
- 在 `passMinSpreadCheck` 内根据 `strategy.windowStartSeconds/windowEndSeconds` 和 `System.currentTimeMillis()`(毫秒)算 `progress`(按毫秒)→ `coefficient` → `effectiveMinSpread = baseSpread × coefficient`。
|
||||
|
||||
### 3. 接口形态建议
|
||||
|
||||
- **BinanceKlineAutoSpreadService**:
|
||||
- `computeAndCache(interval, periodStartUnix)`:只缓存 100% 基准 (baseUp, baseDown),不再乘 0.7。
|
||||
- `getAutoMinSpreadBase(interval, periodStartUnix, outcomeIndex): BigDecimal?`:仅返回缓存的基础价差;若需兼容旧名,可保留 `getAutoMinSpread` 但增加可选参数 `coefficient`,默认 1.0。
|
||||
- **CryptoTailStrategyExecutionService**:
|
||||
- 在 `passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)` 内:
|
||||
- 取 `baseSpread = getAutoMinSpreadBase(...)`。
|
||||
- 计算 `progress`(按方案一用 windowStart/End,或方案二用 interval)。
|
||||
- `coefficient = 1.0 - 0.5 * progress`,再 `effectiveMinSpread = baseSpread * coefficient` 做比较。
|
||||
|
||||
### 4. 边界与兼容
|
||||
|
||||
- 窗口长度为 0:可约定 `coefficient = 0.5` 或 1.0,避免除零。
|
||||
- 已有策略未配置窗口(全 0):若用方案一,可退化为「整周期」或固定 0.5/1.0」。
|
||||
- 预加载逻辑(如 CryptoTailOrderbookWsService 的 `precomputeAutoMinSpreadForCurrentPeriods`)无需改,仍调用 `computeAndCache`,只是缓存内容变为 100% 基准。
|
||||
|
||||
---
|
||||
|
||||
## 小结
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------------|------|
|
||||
| 预加载 | 缓存 100% 基础价差(去掉固定 0.7) |
|
||||
| 系数范围 | 100% → 50% 线性递减 |
|
||||
| 推荐区间 | 按触发窗口 `windowStartSeconds`~`windowEndSeconds` 计算进度(方案一) |
|
||||
| progress | **按毫秒计算**:`(nowMs - windowStartMs) / windowLenMs`,避免秒级舍入误差 |
|
||||
| 计算位置 | 触发时在 `passMinSpreadCheck` 中算 progress → coefficient → effectiveMinSpread |
|
||||
|
||||
按上述实现后,AUTO 模式即为「预加载提供 100% 数值 + 随区间时间点从 100% 递减到 50%」的动态方案。
|
||||
@@ -0,0 +1,469 @@
|
||||
# Crypto Spread Strategy Configuration Guide
|
||||
|
||||
## Part 1: What is Crypto Spread Strategy?
|
||||
|
||||
Crypto Spread Strategy is an automated trading strategy designed specifically for Polymarket crypto markets' **5-minute** or **15-minute** "Up or Down" markets.
|
||||
|
||||
**Core Logic**: Within a specified time window, when the market price enters your set price range, the system will automatically buy at a fixed price (0.99) without manual operation.
|
||||
|
||||
**Use Cases**:
|
||||
- You want to capture price fluctuations at the end of market cycles
|
||||
- You want to automate trading execution and avoid manual monitoring
|
||||
- You have some judgment about market trends and want to set conditions for automatic triggering
|
||||
|
||||
---
|
||||
|
||||
## Part 2: How the Strategy Works
|
||||
|
||||
### 2.1 Basic Flow
|
||||
|
||||
```
|
||||
Cycle Start → Within Time Window → Price Enters Range → Auto Order
|
||||
```
|
||||
|
||||
1. **Cycle**: Each market runs on fixed cycles (5 minutes or 15 minutes)
|
||||
- 5-minute market: Every 5 minutes is a cycle (e.g., 10:00, 10:05, 10:10...)
|
||||
- 15-minute market: Every 15 minutes is a cycle (e.g., 10:00, 10:15, 10:30...)
|
||||
|
||||
2. **Time Window**: You can set a time period within the cycle
|
||||
- Example: 15-minute market, set window to "3 minutes ~ 12 minutes"
|
||||
- Meaning: Only triggers between the 3rd and 12th minute after cycle start
|
||||
|
||||
3. **Price Range**: Set the trigger price range
|
||||
- Example: Minimum price 0.50, Maximum price 0.80
|
||||
- Meaning: Only triggers when market price is between 0.50 ~ 0.80
|
||||
|
||||
4. **Auto Order**: After conditions are met, the system automatically buys at price 0.99
|
||||
|
||||
### 2.2 Important Limitations
|
||||
|
||||
- **Maximum one trigger per cycle**: Within the same cycle, even if conditions are met multiple times, only one order is placed
|
||||
- **Fixed order price**: All orders are submitted at price 0.99
|
||||
- **Requires separate wallet**: It's recommended to use a dedicated wallet for crypto spread strategies to avoid conflicts with other operations (manual trading, copy trading, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Parameter Details
|
||||
|
||||
### 3.1 Basic Parameters
|
||||
|
||||
| Parameter | Description | Required | Example |
|
||||
|-----------|-------------|----------|---------|
|
||||
| **Account** | Select the wallet account for trading | ✅ | Account A |
|
||||
| **Strategy Name** | Name your strategy for easy identification | ❌ | "BTC 15min Crypto Spread Strategy" |
|
||||
| **Market** | Select the market to trade (5-minute or 15-minute) | ✅ | btc-updown-15m |
|
||||
|
||||
### 3.2 Cycle Settings
|
||||
|
||||
| Parameter | Description | Required | Example |
|
||||
|-----------|-------------|----------|---------|
|
||||
| **Cycle Length** | Automatically determined by selected market | ✅ | 15 minutes (900 seconds) |
|
||||
| **Time Window Start** | Minutes after cycle start to begin monitoring | ✅ | 3 min 0 sec |
|
||||
| **Time Window End** | Minutes after cycle start to stop monitoring | ✅ | 12 min 0 sec |
|
||||
|
||||
**Time Window Explanation**:
|
||||
- 5-minute market: Can choose any time period within 0 ~ 5 minutes
|
||||
- 15-minute market: Can choose any time period within 0 ~ 15 minutes
|
||||
- **Start time must ≤ End time**
|
||||
- Times outside the window won't trigger even if price conditions are met
|
||||
|
||||
**Example**:
|
||||
- 15-minute market, window "3 min 0 sec ~ 12 min 0 sec"
|
||||
- 0 ~ 3 minutes after cycle start: Not monitoring
|
||||
- 3 ~ 12 minutes after cycle start: Monitoring price, triggers when conditions met
|
||||
- 12 ~ 15 minutes after cycle start: Not monitoring
|
||||
|
||||
### 3.3 Price Range
|
||||
|
||||
| Parameter | Description | Required | Range | Example |
|
||||
|-----------|-------------|----------|-------|---------|
|
||||
| **Minimum Price (minPrice)** | Minimum trigger price | ✅ | 0 ~ 1 | 0.50 |
|
||||
| **Maximum Price (maxPrice)** | Maximum trigger price | ❌ | 0 ~ 1, default 1 | 0.80 |
|
||||
|
||||
**Price Range Explanation**:
|
||||
- Price range is a decimal between 0 ~ 1
|
||||
- Only triggers when market price is within [Minimum Price, Maximum Price]
|
||||
- If maximum price is not filled, defaults to 1.0 (triggers as long as price ≥ minimum price)
|
||||
|
||||
**Example**:
|
||||
- Minimum price 0.50, Maximum price 0.80
|
||||
- Price 0.45: Not triggered (below minimum)
|
||||
- Price 0.60: Triggered ✅ (within range)
|
||||
- Price 0.85: Not triggered (above maximum)
|
||||
|
||||
### 3.4 Investment Amount
|
||||
|
||||
| Parameter | Description | Required | Example |
|
||||
|-----------|-------------|----------|---------|
|
||||
| **Investment Mode** | Choose ratio or fixed amount | ✅ | Ratio / Fixed Amount |
|
||||
| **Ratio (%)** | Percentage of account balance to invest | Conditionally required | 10% (Account has 100 USDC, invest 10 USDC) |
|
||||
| **Fixed Amount (USDC)** | Fixed amount to invest each time | Conditionally required | 50 USDC |
|
||||
|
||||
**Investment Mode Explanation**:
|
||||
|
||||
**Mode 1: By Ratio (RATIO)**
|
||||
- Each trigger invests a percentage of current available balance
|
||||
- Example: Account has 100 USDC, set ratio to 10%
|
||||
- 1st trigger: Invest 10 USDC
|
||||
- 2nd trigger: If balance becomes 90 USDC, invest 9 USDC
|
||||
- **Advantages**: Automatically adapts to account balance changes
|
||||
- **Disadvantages**: Investment amount may vary each time
|
||||
|
||||
**Mode 2: Fixed Amount (FIXED)**
|
||||
- Each trigger invests a fixed specified amount
|
||||
- Example: Set fixed amount to 50 USDC
|
||||
- Every trigger invests 50 USDC
|
||||
- **Advantages**: Stable investment amount, easy to manage
|
||||
- **Disadvantages**: Need to ensure sufficient account balance
|
||||
|
||||
**Notes**:
|
||||
- Minimum order amount: At least 1 USDC
|
||||
- If account balance is insufficient, order will fail and record failure reason
|
||||
|
||||
### 3.5 Spread Filter (Advanced Feature)
|
||||
|
||||
The spread filter controls whether to trigger based on Binance BTC/USDC K-line volatility. It supports two directions: **Minimum spread** and **Maximum spread**.
|
||||
|
||||
| Parameter | Description | Required | Example |
|
||||
|-----------|-------------|----------|---------|
|
||||
| **Spread Mode** | Choose spread validation method | ✅ | None / Fixed / Auto |
|
||||
| **Spread Direction** | Min spread (trigger when ≥) or Max spread (trigger when ≤) | ✅ | Min spread / Max spread |
|
||||
| **Spread Value** | Fill when using Fixed mode (unit: USDC) | Conditionally required | 30 |
|
||||
|
||||
**Spread Direction**:
|
||||
|
||||
- **Min spread**: Triggers only when Binance K-line spread **≥** the set value
|
||||
- Use when you want to trade only when volatility is "large enough" (avoid entering when volatility is too small).
|
||||
- **Max spread**: Triggers only when Binance K-line spread **≤** the set value
|
||||
- Use when you want to trade only when volatility is "small enough" (avoid entering when volatility is too high).
|
||||
|
||||
**Three Spread Modes**:
|
||||
|
||||
**Mode 1: None (NONE)**
|
||||
- No spread validation
|
||||
- Triggers as long as time window and price range conditions are met
|
||||
- **Suitable for**: Not concerned about Binance price volatility, only watching Polymarket price
|
||||
|
||||
**Mode 2: Fixed (FIXED)**
|
||||
- Set a fixed spread value (unit: USDC)
|
||||
- **Min spread**: Triggers when K-line spread ≥ set value
|
||||
- Example: Set 30, spread ≥ 30 → triggered ✅, spread < 30 → not triggered
|
||||
- **Max spread**: Triggers when K-line spread ≤ set value
|
||||
- Example: Set 50, spread ≤ 50 → triggered ✅, spread > 50 → not triggered
|
||||
- **Suitable for**: You have a clear spread threshold in mind
|
||||
|
||||
**Mode 3: Auto (AUTO)**
|
||||
- System automatically calculates an effective spread from the last 20 K-lines
|
||||
- Calculation logic:
|
||||
1. Get recent 20 K-lines (matching strategy cycle)
|
||||
2. Filter by direction (Up direction only looks at rising K-lines, Down direction only looks at falling K-lines)
|
||||
3. Remove outliers (using IQR method)
|
||||
4. Calculate average spread × 0.8 as effective spread
|
||||
- **Min spread**: Triggers when K-line spread ≥ effective spread
|
||||
- **Max spread**: Triggers when K-line spread ≤ effective spread
|
||||
- **Suitable for**: Want automatic adjustment based on historical data without setting a specific value
|
||||
|
||||
**Spread Explanation**:
|
||||
- Spread = |close price - open price| (Binance BTC/USDC for that K-line)
|
||||
- Example: Open price 50000, close price 50030, spread = 30
|
||||
- Larger spread means greater price volatility in that cycle
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Configuration Examples
|
||||
|
||||
### Example 1: Simple Strategy (5-minute Market)
|
||||
|
||||
**Scenario**: In the last 2 minutes of a 5-minute market, if price is below 0.60, automatically buy 10 USDC
|
||||
|
||||
**Configuration**:
|
||||
```
|
||||
Account: Account A
|
||||
Strategy Name: BTC 5min Simple Strategy
|
||||
Market: btc-updown-5m
|
||||
Time Window: 3 min 0 sec ~ 5 min 0 sec
|
||||
Minimum Price: 0.00
|
||||
Maximum Price: 0.60
|
||||
Investment Mode: Fixed Amount
|
||||
Fixed Amount: 10 USDC
|
||||
Spread Mode: None
|
||||
Enabled: On
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- 0 ~ 3 minutes after cycle start: Not monitoring
|
||||
- 3 ~ 5 minutes after cycle start: If price ≤ 0.60, automatically buy 10 USDC
|
||||
|
||||
---
|
||||
|
||||
### Example 2: Ratio Investment Strategy (15-minute Market)
|
||||
|
||||
**Scenario**: In the middle segment (5 ~ 10 minutes) of a 15-minute market, if price is between 0.40 ~ 0.70, invest 15% of account balance
|
||||
|
||||
**Configuration**:
|
||||
```
|
||||
Account: Account B
|
||||
Strategy Name: BTC 15min Ratio Strategy
|
||||
Market: btc-updown-15m
|
||||
Time Window: 5 min 0 sec ~ 10 min 0 sec
|
||||
Minimum Price: 0.40
|
||||
Maximum Price: 0.70
|
||||
Investment Mode: By Ratio
|
||||
Ratio: 15%
|
||||
Spread Mode: None
|
||||
Enabled: On
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- Assuming account balance is 100 USDC
|
||||
- 5 ~ 10 minutes after cycle start: If price is between 0.40 ~ 0.70, automatically buy about 15 USDC (100 × 15%)
|
||||
|
||||
---
|
||||
|
||||
### Example 3: Strategy with Spread Filter (15-minute Market)
|
||||
|
||||
**Scenario**: In the latter segment (10 ~ 14 minutes) of a 15-minute market, if price is between 0.50 ~ 0.80 and Binance spread ≥ 50, invest 20 USDC
|
||||
|
||||
**Configuration**:
|
||||
```
|
||||
Account: Account C
|
||||
Strategy Name: BTC 15min Spread Strategy
|
||||
Market: btc-updown-15m
|
||||
Time Window: 10 min 0 sec ~ 14 min 0 sec
|
||||
Minimum Price: 0.50
|
||||
Maximum Price: 0.80
|
||||
Investment Mode: Fixed Amount
|
||||
Fixed Amount: 20 USDC
|
||||
Spread Mode: Fixed
|
||||
Spread Direction: Min spread
|
||||
Spread Value: 50
|
||||
Enabled: On
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- 10 ~ 14 minutes after cycle start: Only triggers when both conditions are met:
|
||||
1. Price is between 0.50 ~ 0.80 ✅
|
||||
2. Spread direction is "Min spread" and Binance spread ≥ 50 ✅
|
||||
- If spread is only 30, won't trigger even if price condition is met
|
||||
|
||||
---
|
||||
|
||||
### Example 4: Auto Spread Strategy (15-minute Market)
|
||||
|
||||
**Scenario**: In the early segment (2 ~ 8 minutes) of a 15-minute market, if price is between 0.30 ~ 0.90, invest 20% of account balance, spread calculated automatically by system
|
||||
|
||||
**Configuration**:
|
||||
```
|
||||
Account: Account D
|
||||
Strategy Name: BTC 15min Auto Spread Strategy
|
||||
Market: btc-updown-15m
|
||||
Time Window: 2 min 0 sec ~ 8 min 0 sec
|
||||
Minimum Price: 0.30
|
||||
Maximum Price: 0.90
|
||||
Investment Mode: By Ratio
|
||||
Ratio: 20%
|
||||
Spread Mode: Auto
|
||||
Spread Direction: Min spread
|
||||
Enabled: On
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- System automatically calculates effective spread from the last 20 K-lines
|
||||
- 2 ~ 8 minutes after cycle start: Only triggers when both conditions are met:
|
||||
1. Price is between 0.30 ~ 0.90 ✅
|
||||
2. Spread direction is "Min spread" and Binance spread ≥ system-calculated effective spread ✅
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Frequently Asked Questions
|
||||
|
||||
### Q1: When will the strategy trigger?
|
||||
|
||||
**A**: All of the following conditions must be met simultaneously:
|
||||
1. ✅ Current time is within the time window
|
||||
2. ✅ Market price is within [Minimum Price, Maximum Price] range
|
||||
3. ✅ This cycle hasn't triggered yet (maximum one trigger per cycle)
|
||||
4. ✅ If spread filter is set, Binance spread and spread direction must both be satisfied
|
||||
|
||||
### Q2: Why didn't my strategy trigger?
|
||||
|
||||
**Possible reasons**:
|
||||
1. **Time window incorrect**: Current time is not within the set time window
|
||||
2. **Price not in range**: Market price is not within [Minimum Price, Maximum Price] range
|
||||
3. **Already triggered this cycle**: This cycle has already triggered once, won't trigger again
|
||||
4. **Spread not met**: If spread filter is set, Binance spread or spread direction requirement is not satisfied
|
||||
5. **Insufficient account balance**: Account balance is less than the set investment amount
|
||||
6. **Strategy not enabled**: Check if strategy's enabled status is "On"
|
||||
|
||||
### Q3: What does "maximum one trigger per cycle" mean?
|
||||
|
||||
**A**: Within each cycle (5 minutes or 15 minutes), even if conditions are met multiple times, only one order is placed.
|
||||
|
||||
**Example**:
|
||||
- 15-minute market, cycle starts at 10:00
|
||||
- At 10:05, price meets condition, triggers order ✅
|
||||
- At 10:08, price meets condition again, but won't place another order (already triggered this cycle)
|
||||
- At 10:15, new cycle starts, can trigger again
|
||||
|
||||
### Q4: What's the difference between fixed amount and ratio?
|
||||
|
||||
**Fixed Amount**:
|
||||
- Invests the same amount each trigger
|
||||
- Example: Set 50 USDC, every trigger is 50 USDC
|
||||
- Need to ensure sufficient account balance
|
||||
|
||||
**By Ratio**:
|
||||
- Invests a percentage of account balance each trigger
|
||||
- Example: Set 10%, when account has 100 USDC, invest 10 USDC, after balance becomes 90 USDC, next trigger invests 9 USDC
|
||||
- Automatically adapts to balance changes
|
||||
|
||||
### Q5: What's the use of the spread filter feature?
|
||||
|
||||
**A**: The spread filter decides whether to trigger based on Binance BTC/USDC K-line volatility. It supports two directions.
|
||||
|
||||
**Min spread** (trigger when spread **≥** set value):
|
||||
- Avoids triggering when volatility is too small
|
||||
- Example: Set 30, only triggers when spread ≥ 30
|
||||
|
||||
**Max spread** (trigger when spread **≤** set value):
|
||||
- Avoids triggering when volatility is too high (lower risk)
|
||||
- Example: Set 50, only triggers when spread ≤ 50
|
||||
|
||||
**Three mode selection suggestions**:
|
||||
- **None**: Not concerned about Binance price volatility, only watching Polymarket price
|
||||
- **Fixed**: You know the expected spread threshold (use with Min or Max spread direction)
|
||||
- **Auto**: Want effective spread calculated from historical data without setting a specific value
|
||||
|
||||
### Q6: Why is it recommended to use a separate wallet?
|
||||
|
||||
**A**: To avoid the following issues:
|
||||
1. **Balance changes**: If wallet is also used for manual trading, balance changes may affect strategy execution
|
||||
2. **Position conflicts**: Manual trading and strategy trading may conflict
|
||||
3. **Management confusion**: Difficult to distinguish which orders are from strategy vs manual
|
||||
|
||||
**Recommendation**: Create a dedicated wallet, only for crypto spread strategies.
|
||||
|
||||
### Q7: Why is the order price fixed at 0.99?
|
||||
|
||||
**A**: This is a design feature of the strategy:
|
||||
- 0.99 is the highest price in the market (close to 1.0)
|
||||
- Buying at the highest price ensures orders execute quickly
|
||||
- Although buying price is higher, the strategy's core is capturing market volatility, not pursuing optimal price
|
||||
|
||||
### Q8: Does the strategy depend on auto-redeem functionality?
|
||||
|
||||
**A**: Yes, crypto spread strategy depends on auto-redeem functionality.
|
||||
|
||||
**Reasons**:
|
||||
- Strategy orders create positions after execution
|
||||
- These positions need to be automatically redeemed after market settlement
|
||||
- If auto-redeem is not configured, positions may not be redeemed in time
|
||||
|
||||
**Configuration Requirements**:
|
||||
- Configure Builder API Key in "System Settings"
|
||||
- Enable auto-redeem functionality
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Important Notes
|
||||
|
||||
### 6.1 Account Requirements
|
||||
|
||||
- ✅ Account must have API Key, API Secret, API Passphrase configured
|
||||
- ✅ Account must have sufficient USDC balance
|
||||
- ✅ Recommended to use a dedicated wallet to avoid conflicts with other operations
|
||||
|
||||
### 6.2 Time Window Settings
|
||||
|
||||
- ⚠️ Start time must ≤ End time
|
||||
- ⚠️ Time window cannot exceed cycle length (5-minute market ≤ 5 minutes, 15-minute market ≤ 15 minutes)
|
||||
- ⚠️ Recommended to set reasonable time windows, avoid triggering at cycle start or end
|
||||
|
||||
### 6.3 Price Range Settings
|
||||
|
||||
- ⚠️ Minimum price must ≤ Maximum price
|
||||
- ⚠️ Price range is a decimal between 0 ~ 1
|
||||
- ⚠️ Recommended to set reasonable price ranges based on market conditions
|
||||
|
||||
### 6.4 Investment Amount Settings
|
||||
|
||||
- ⚠️ Minimum order amount: At least 1 USDC
|
||||
- ⚠️ Ensure sufficient account balance to avoid order failures
|
||||
- ⚠️ Ratio mode: Note the impact of account balance changes on investment amount
|
||||
|
||||
### 6.5 Spread Filter Settings
|
||||
|
||||
- ⚠️ Spread direction: Min spread means "trigger when ≥"; Max spread means "trigger when ≤". Choose according to your need.
|
||||
- ⚠️ Fixed mode: Need to fill a reasonable spread value (unit: USDC)
|
||||
- ⚠️ Auto mode: System calculates effective spread within the window, no manual value needed
|
||||
- ⚠️ Overly strict spread (min spread too high or max spread too low) may make the strategy rarely trigger
|
||||
|
||||
### 6.6 Other Notes
|
||||
|
||||
- ⚠️ Strategy is enabled by default after creation, can disable "Enabled Status" if need to pause
|
||||
- ⚠️ Maximum one trigger per cycle, set trigger conditions reasonably
|
||||
- ⚠️ Strategy depends on auto-redeem functionality, ensure Builder API Key is configured
|
||||
- ⚠️ Recommended to regularly check trigger records to understand strategy execution
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Strategy Management
|
||||
|
||||
### 7.1 View Strategy List
|
||||
|
||||
On the "Crypto Spread Strategy" page, you can view all strategies:
|
||||
- Strategy name
|
||||
- Market information
|
||||
- Time window
|
||||
- Price range
|
||||
- Investment mode
|
||||
- Enabled status
|
||||
- Last trigger time
|
||||
- Statistics like total profit, win rate
|
||||
|
||||
### 7.2 View Trigger Records
|
||||
|
||||
Click on a strategy to view detailed trigger records:
|
||||
- Trigger time
|
||||
- Market price
|
||||
- Investment amount
|
||||
- Order ID
|
||||
- Order status (success/fail)
|
||||
- Settlement information (profit/loss, win rate, etc.)
|
||||
|
||||
### 7.3 Edit Strategy
|
||||
|
||||
You can modify strategy parameters at any time:
|
||||
- Time window
|
||||
- Price range
|
||||
- Investment mode
|
||||
- Spread filter (mode, direction, value)
|
||||
- Enabled status
|
||||
|
||||
**Note**: Modified strategies take effect in the next cycle.
|
||||
|
||||
### 7.4 Delete Strategy
|
||||
|
||||
After deleting a strategy:
|
||||
- Strategy configuration is deleted
|
||||
- Historical trigger records are retained
|
||||
- Already placed orders are not affected
|
||||
|
||||
---
|
||||
|
||||
## Part 8: Summary
|
||||
|
||||
Crypto Spread Strategy is a powerful automated trading tool that can help you:
|
||||
|
||||
1. **Automated Trading**: No need for manual monitoring, system executes automatically
|
||||
2. **Precise Control**: Precisely control trigger conditions through time windows and price ranges
|
||||
3. **Flexible Configuration**: Supports both ratio and fixed amount investment modes
|
||||
4. **Risk Filtering**: Control volatility conditions through spread filter (min spread / max spread)
|
||||
|
||||
**Usage Recommendations**:
|
||||
- For first-time users, start with simple strategies (no spread filter)
|
||||
- After familiarizing, try adding spread filter features
|
||||
- Regularly check trigger records, adjust strategy parameters based on actual situation
|
||||
- Use a dedicated wallet to avoid conflicts with other operations
|
||||
|
||||
**Happy Trading!** 🚀
|
||||
@@ -0,0 +1,204 @@
|
||||
# 加密价差策略 - 流程图
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ POST 创建/更新 ┌──────────────────────────┐
|
||||
│ 前端 / API │ ──────────────────────►│ CryptoTailStrategyController│
|
||||
└─────────────────┘ └──────────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ CryptoTailStrategyService │
|
||||
│ create / update │
|
||||
│ save → publishEvent │
|
||||
└──────────────┬─────────────┘
|
||||
│
|
||||
┌─────────────────────────────────────────┼─────────────────────────────────────────┐
|
||||
│ CryptoTailStrategyChangedEvent │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ CryptoTailStrategyScheduler │ │ CryptoTailOrderbookWsService │ │ (其他监听方,如有) │
|
||||
│ @EventListener │ │ @EventListener │ └──────────────────────────────┘
|
||||
│ → runCycle() 一次(补充) │ │ → refreshAndSubscribe() │
|
||||
└──────────────┬───────────────┘ └──────────────┬───────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────┐ │
|
||||
│ CryptoTailStrategyExecution │ │ 每 25 秒 + 事件时
|
||||
│ runCycle() │ │ refreshAndSubscribe()
|
||||
│ (HTTP 拉订单簿,满足则下单) │ ▼
|
||||
└──────────────────────────────┘ ┌──────────────────────────────┐
|
||||
│ CLOB Market WebSocket │
|
||||
│ wss://.../ws/market │
|
||||
│ subscribe assets_ids │
|
||||
└──────────────┬───────────────┘
|
||||
│ book / price_change
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ onBestBid(tokenId, bestBid) │
|
||||
│ → tryTriggerWithPriceFromWs │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ CryptoTailStrategyExecution │
|
||||
│ placeOrderForTrigger │
|
||||
│ → CLOB 下单 + 写触发记录 │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、策略创建/更新流程(API → 事件)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant API as Controller
|
||||
participant Svc as CryptoTailStrategyService
|
||||
participant DB as DB
|
||||
participant Event as ApplicationEventPublisher
|
||||
|
||||
API->>Svc: create(request) / update(request)
|
||||
Svc->>Svc: 参数校验(账户、窗口、价格、金额模式等)
|
||||
Svc->>DB: save(entity)
|
||||
Svc->>Event: publishEvent(CryptoTailStrategyChangedEvent)
|
||||
Svc->>API: Result.success(dto)
|
||||
```
|
||||
|
||||
- **创建**:校验通过后落库,发布 `CryptoTailStrategyChangedEvent`,返回 DTO。
|
||||
- **更新**:同上,更新实体后发布同一事件。
|
||||
- **删除**:不发布事件(策略已移除,WS 下次刷新订阅时会自然不再包含该策略)。
|
||||
|
||||
---
|
||||
|
||||
## 三、策略变更后:双路响应
|
||||
|
||||
事件发出后,两个监听方并行执行,互不阻塞:
|
||||
|
||||
| 监听方 | 动作 | 说明 |
|
||||
|--------|------|------|
|
||||
| **CryptoTailStrategyScheduler** | `onStrategyChanged` → `runCycle()` 一次 | 用 HTTP 拉订单簿做一轮检查,作为 WS 未就绪时的补充。 |
|
||||
| **CryptoTailOrderbookWsService** | `onStrategyChanged` → `refreshAndSubscribe()` | 按当前启用策略重新算 token 列表,向 WS 发送新的 `assets_ids` 订阅。 |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 事件
|
||||
E[CryptoTailStrategyChangedEvent]
|
||||
end
|
||||
subgraph 调度器
|
||||
S[Scheduler.onStrategyChanged]
|
||||
R[executionService.runCycle]
|
||||
S --> R
|
||||
end
|
||||
subgraph WS服务
|
||||
W[OrderbookWsService.onStrategyChanged]
|
||||
Ref[refreshAndSubscribe]
|
||||
W --> Ref
|
||||
end
|
||||
E --> S
|
||||
E --> W
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、WebSocket 订单簿监听流程(主路径)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph 启动与连接
|
||||
A[PostConstruct init] --> B[connect]
|
||||
B --> C[OkHttp WebSocket 连接 wss://.../ws/market]
|
||||
C --> D[onOpen: refreshAndSubscribe]
|
||||
end
|
||||
|
||||
subgraph 订阅维护
|
||||
D --> E[buildSubscriptionMap]
|
||||
E --> F[遍历 enabled 策略]
|
||||
F --> G[当前周期 periodStartUnix]
|
||||
G --> H[slug = prefix-periodStartUnix]
|
||||
H --> I[Gamma getEventBySlug]
|
||||
I --> J[得到 tokenIds]
|
||||
J --> K[tokenId → List of WsBookEntry]
|
||||
K --> L[发送 type=MARKET, assets_ids=[...]]
|
||||
T[每 25 秒 @Scheduled] --> E
|
||||
EV[onStrategyChanged] --> E
|
||||
end
|
||||
|
||||
subgraph 收消息与触发
|
||||
M[onMessage: book / price_change]
|
||||
M --> N[解析 asset_id, best_bid]
|
||||
N --> O[onBestBid tokenId, bestBid]
|
||||
O --> P[查 tokenToEntries 得到策略列表]
|
||||
P --> Q[筛时间窗内]
|
||||
Q --> R[scope.launch tryTriggerWithPriceFromWs]
|
||||
R --> S[placeOrderForTrigger]
|
||||
end
|
||||
|
||||
L --> M
|
||||
```
|
||||
|
||||
- **buildSubscriptionMap**:只包含「当前时间仍在窗口内」的策略(`nowSeconds < windowEnd`),并只订阅这些策略对应周期的 token。
|
||||
- **onBestBid**:再按当前时间过滤一次时间窗,对每个命中策略在协程里调用 `tryTriggerWithPriceFromWs`,内部会查「本周期是否已触发」和价格区间,通过则 `placeOrderForTrigger`。
|
||||
|
||||
---
|
||||
|
||||
## 五、执行层:下单条件与顺序(ExecutionService)
|
||||
|
||||
无论来自 **runCycle(HTTP)** 还是 **tryTriggerWithPriceFromWs(WS)**,最终都走同一套下单逻辑。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph runCycle 入口
|
||||
A[runCycle] --> B[findAllByEnabledTrue]
|
||||
B --> C[processStrategy 每个策略]
|
||||
C --> D[在时间窗? 本周期已触发?]
|
||||
D --> E[Gamma getEventBySlug]
|
||||
E --> F[HTTP getOrderbook 两个 token]
|
||||
F --> G[第一个 bestBid 在 minPrice~maxPrice?]
|
||||
G --> H[placeOrderForTrigger]
|
||||
end
|
||||
|
||||
subgraph tryTriggerWithPriceFromWs 入口
|
||||
I[WS onBestBid] --> J[tryTriggerWithPriceFromWs]
|
||||
J --> K[本周期已触发? bestBid 在区间?]
|
||||
K --> H
|
||||
end
|
||||
|
||||
subgraph placeOrderForTrigger 统一
|
||||
H --> L[账户、API 凭证]
|
||||
L --> M[余额、下单金额]
|
||||
M --> N[最优价、数量]
|
||||
N --> O[签名、CLOB 下单]
|
||||
O --> P[保存 CryptoTailStrategyTrigger]
|
||||
end
|
||||
```
|
||||
|
||||
- **每周期最多触发一次**:由 `triggerRepository.findByStrategyIdAndPeriodStartUnix` 保证。
|
||||
- **价格区间**:`minPrice ≤ bestBid ≤ maxPrice` 才触发。
|
||||
- **时间窗**:仅当 `windowStart ≤ now < windowEnd`(以当前周期的 `periodStartUnix` 为基准)才参与检查/下单。
|
||||
|
||||
---
|
||||
|
||||
## 六、关键数据流小结
|
||||
|
||||
| 阶段 | 输入 | 输出/动作 |
|
||||
|------|------|-----------|
|
||||
| 创建/更新策略 | API 请求体 | 落库 + 发布 `CryptoTailStrategyChangedEvent` |
|
||||
| 事件 → 调度器 | 事件 | 执行一次 `runCycle()`(HTTP 拉订单簿,满足则下单) |
|
||||
| 事件 → WS 服务 | 事件 | `refreshAndSubscribe()`,更新订阅的 `assets_ids` |
|
||||
| 定时刷新订阅 | 每 25 秒 | `refreshAndSubscribe()`,保证新周期、新策略被订阅 |
|
||||
| WS 收 book/price_change | asset_id, best_bid | `onBestBid` → 时间窗内策略 → `tryTriggerWithPriceFromWs` → 未触发且价格在区间则 `placeOrderForTrigger` |
|
||||
| placeOrderForTrigger | 策略、周期、token、outcome、价格 | 账户/余额/价格/签名 → CLOB 下单 → 写触发记录 |
|
||||
|
||||
---
|
||||
|
||||
## 七、涉及类与职责
|
||||
|
||||
| 类 | 职责 |
|
||||
|----|------|
|
||||
| **CryptoTailStrategyController** | 接收 list/create/update/delete/triggers/marketOptions 的 POST。 |
|
||||
| **CryptoTailStrategyService** | 策略 CRUD、校验、发布 `CryptoTailStrategyChangedEvent`。 |
|
||||
| **CryptoTailStrategyScheduler** | 监听策略变更事件,执行一次 `runCycle()`。 |
|
||||
| **CryptoTailOrderbookWsService** | 连接 CLOB Market WS、维护订阅(事件 + 每 25 秒)、处理 book/price_change、调用 `tryTriggerWithPriceFromWs`。 |
|
||||
| **CryptoTailStrategyExecutionService** | `runCycle()`(HTTP 路径)、`tryTriggerWithPriceFromWs()`(WS 路径)、`placeOrderForTrigger()`(统一下单与写触发记录)。 |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user