Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cb48bb6cc | ||
|
|
e78785de7c | ||
|
|
46c32df421 | ||
|
|
686d14b6e5 | ||
|
|
561ebf0ce3 | ||
|
|
b8e10f340c | ||
|
|
9c70dc762f | ||
|
|
8f0a49493c | ||
|
|
e7af4d4821 | ||
|
|
83bc209489 | ||
|
|
9b6cc63158 | ||
|
|
96d224a5a3 | ||
|
|
97249db546 | ||
|
|
46e10ebdd8 | ||
|
|
4ebfacfc21 | ||
|
|
f86749e85d | ||
|
|
3cc37dac55 | ||
|
|
5483a1aa77 | ||
|
|
e5acad5091 | ||
|
|
1688fa9633 | ||
|
|
1967d97c31 | ||
|
|
79b154515d | ||
|
|
9b4d8fc001 | ||
|
|
cb1f43871a | ||
|
|
a2be5b7f52 | ||
|
|
708d6ddb41 | ||
|
|
c5d59dfdf9 | ||
|
|
4b277eaeab | ||
|
|
f35bad78d4 | ||
|
|
0740abcf16 | ||
|
|
377da4fff6 | ||
|
|
84c79d8812 | ||
|
|
d3196a783f | ||
|
|
4c989a48c4 | ||
|
|
5bb46ebb97 | ||
|
|
cbcebf6e28 | ||
|
|
2013a2eb70 | ||
|
|
de89175c8e | ||
|
|
4e1bb0bbcf | ||
|
|
24a4487d0b | ||
|
|
24bb7bed40 | ||
|
|
6b56f62532 | ||
|
|
c38529546f | ||
|
|
08e1219f77 | ||
|
|
62842f5aa8 | ||
|
|
13662dd0d5 | ||
|
|
07c3548401 | ||
|
|
364db124d1 |
@@ -14,6 +14,10 @@
|
|||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── backend/ # 后端相关 skill
|
├── backend/ # 后端相关 skill
|
||||||
└── common/ # 通用 skill
|
└── common/ # 通用 skill
|
||||||
|
└── create-release/ # 创建 GitHub Release
|
||||||
|
├── SKILL.md
|
||||||
|
└── scripts/
|
||||||
|
└── create-release.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
- **SKILL.md**:YAML frontmatter(`name`、`description` 必填,`name` 须与父文件夹名一致、小写连字符)+ 给 Agent 的详细指令。
|
- **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
|
## 运行 check-i18n-keys
|
||||||
|
|
||||||
@@ -37,3 +42,17 @@ npm install
|
|||||||
npm run check-i18n
|
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 "$@"
|
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
|
||||||
@@ -6,14 +6,6 @@ on:
|
|||||||
- published # 当通过 GitHub Releases 页面创建 release 时触发
|
- published # 当通过 GitHub Releases 页面创建 release 时触发
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
build_type:
|
|
||||||
description: '构建类型'
|
|
||||||
required: true
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- package-only # 只打包产物
|
|
||||||
- package-and-docker # 打包产物 + Docker 镜像
|
|
||||||
default: 'package-and-docker'
|
|
||||||
version:
|
version:
|
||||||
description: '版本号(例如: v1.0.0)'
|
description: '版本号(例如: v1.0.0)'
|
||||||
required: false
|
required: false
|
||||||
@@ -36,22 +28,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
|
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
|
- name: Extract version and check if pre-release
|
||||||
id: extract_version
|
id: extract_version
|
||||||
run: |
|
run: |
|
||||||
@@ -100,53 +76,6 @@ jobs:
|
|||||||
echo "📦 这是正式版本: $TAG_NAME"
|
echo "📦 这是正式版本: $TAG_NAME"
|
||||||
fi
|
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
|
- name: Setup JDK 17
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
@@ -263,22 +192,66 @@ jobs:
|
|||||||
checksums.txt
|
checksums.txt
|
||||||
retention-days: 30
|
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
|
- name: Set up Docker Buildx
|
||||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
# 启用多架构构建支持
|
# 启用多架构构建支持
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Prepare Docker build context
|
- name: Prepare Docker build context
|
||||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
|
||||||
run: |
|
run: |
|
||||||
echo "📦 准备 Docker 构建上下文..."
|
echo "📦 准备 Docker 构建上下文..."
|
||||||
# 确保构建产物存在且可访问
|
# 确保构建产物存在且可访问
|
||||||
@@ -295,7 +268,7 @@ jobs:
|
|||||||
ls -lh backend/build/libs/*.jar
|
ls -lh backend/build/libs/*.jar
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- 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
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -314,13 +287,8 @@ jobs:
|
|||||||
cache-from: type=registry,ref=wrbug/polyhermes:latest
|
cache-from: type=registry,ref=wrbug/polyhermes:latest
|
||||||
cache-to: type=inline
|
cache-to: type=inline
|
||||||
|
|
||||||
- name: Skip Docker build notice
|
# ============ 发送 Docker 构建成功通知 ============
|
||||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-only'
|
- name: Send Telegram notification (Docker build completed)
|
||||||
run: |
|
|
||||||
echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only)"
|
|
||||||
echo "✅ 仅打包产物已完成"
|
|
||||||
|
|
||||||
- name: Send Telegram notification
|
|
||||||
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
|
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
|
||||||
env:
|
env:
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
@@ -335,25 +303,14 @@ jobs:
|
|||||||
# 获取构建信息
|
# 获取构建信息
|
||||||
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
||||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
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"
|
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
|
||||||
|
|
||||||
if [ "${{ github.event_name }}" = "release" ]; then
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||||
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
|
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>"
|
||||||
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
|
|
||||||
else
|
else
|
||||||
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
|
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>"
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||||
@@ -371,11 +328,9 @@ jobs:
|
|||||||
echo "✅ Telegram 通知发送成功"
|
echo "✅ Telegram 通知发送成功"
|
||||||
else
|
else
|
||||||
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||||
# 构建成功,通知失败不应该导致整个 job 失败
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "❌ 发送 Telegram 消息时发生错误"
|
echo "❌ 发送 Telegram 消息时发生错误"
|
||||||
# 构建成功,通知失败不应该导致整个 job 失败
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ interface BuilderRelayerApi {
|
|||||||
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
|
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
|
||||||
|
|
||||||
@SerializedName("nonce")
|
@SerializedName("nonce")
|
||||||
val nonce: String, // Safe nonce(字符串)
|
val nonce: String? = null, // Safe nonce(SAFE 必填,SAFE-CREATE 不传)
|
||||||
|
|
||||||
@SerializedName("signature")
|
@SerializedName("signature")
|
||||||
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
|
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
|
||||||
@@ -138,7 +138,17 @@ interface BuilderRelayerApi {
|
|||||||
val relayHub: String? = null,
|
val relayHub: String? = null,
|
||||||
|
|
||||||
@SerializedName("relay")
|
@SerializedName("relay")
|
||||||
val relay: String? = null
|
val relay: String? = null,
|
||||||
|
|
||||||
|
/** SAFE-CREATE 签名参数 */
|
||||||
|
@SerializedName("paymentToken")
|
||||||
|
val paymentToken: String? = null,
|
||||||
|
|
||||||
|
@SerializedName("payment")
|
||||||
|
val payment: String? = null,
|
||||||
|
|
||||||
|
@SerializedName("paymentReceiver")
|
||||||
|
val paymentReceiver: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ data class NewOrderResponse(
|
|||||||
val success: Boolean, // boolean indicating if server-side error
|
val success: Boolean, // boolean indicating if server-side error
|
||||||
@SerializedName("errorMsg")
|
@SerializedName("errorMsg")
|
||||||
val errorMsg: String? = null, // error message in case of unsuccessful placement
|
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")
|
@SerializedName("orderID")
|
||||||
val orderId: String? = null, // id of order(API 返回字段名为 orderID)
|
val orderId: String? = null, // id of order(API 返回字段名为 orderID)
|
||||||
@SerializedName("transactionsHashes")
|
@SerializedName("transactionsHashes")
|
||||||
@@ -222,7 +223,17 @@ data class NewOrderResponse(
|
|||||||
val takingAmount: String? = null, // taking amount
|
val takingAmount: String? = null, // taking amount
|
||||||
@SerializedName("makingAmount")
|
@SerializedName("makingAmount")
|
||||||
val makingAmount: String? = null // making amount
|
val makingAmount: String? = null // making amount
|
||||||
)
|
) {
|
||||||
|
/**
|
||||||
|
* 获取错误信息的便捷方法
|
||||||
|
* 优先返回 errorMsg,其次返回 error,最后返回默认消息
|
||||||
|
*/
|
||||||
|
fun getErrorMessage(): String {
|
||||||
|
return errorMsg?.takeIf { it.isNotBlank() }
|
||||||
|
?: error?.takeIf { it.isNotBlank() }
|
||||||
|
?: "创建订单失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 旧的订单请求格式(已废弃,保留用于兼容)
|
* 旧的订单请求格式(已废弃,保留用于兼容)
|
||||||
|
|||||||
@@ -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 交易
|
* 用于 Gasless 交易
|
||||||
*/
|
*/
|
||||||
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/"
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+76
@@ -204,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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询账户详情
|
* 查询账户详情
|
||||||
*/
|
*/
|
||||||
|
|||||||
+111
-11
@@ -11,9 +11,17 @@ import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListResponse
|
|||||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest
|
import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest
|
||||||
import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto
|
import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto
|
||||||
import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse
|
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.enums.ErrorCode
|
||||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
|
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.slf4j.LoggerFactory
|
||||||
import org.springframework.context.MessageSource
|
import org.springframework.context.MessageSource
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
@@ -21,11 +29,14 @@ import org.springframework.web.bind.annotation.PostMapping
|
|||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/crypto-tail-strategy")
|
@RequestMapping("/api/crypto-tail-strategy")
|
||||||
class CryptoTailStrategyController(
|
class CryptoTailStrategyController(
|
||||||
private val cryptoTailStrategyService: CryptoTailStrategyService,
|
private val cryptoTailStrategyService: CryptoTailStrategyService,
|
||||||
|
private val cryptoTailMonitorService: CryptoTailMonitorService,
|
||||||
|
private val cryptoTailStrategyExecutionService: CryptoTailStrategyExecutionService,
|
||||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||||
private val messageSource: MessageSource
|
private val messageSource: MessageSource
|
||||||
) {
|
) {
|
||||||
@@ -39,12 +50,12 @@ class CryptoTailStrategyController(
|
|||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("查询尾盘策略列表失败: ${e.message}", e)
|
logger.error("查询加密价差策略列表失败: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("查询尾盘策略列表异常: ${e.message}", e)
|
logger.error("查询加密价差策略列表异常: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +67,7 @@ class CryptoTailStrategyController(
|
|||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("创建尾盘策略失败: ${e.message}", e)
|
logger.error("创建加密价差策略失败: ${e.message}", e)
|
||||||
val code = when (e.message) {
|
val code = when (e.message) {
|
||||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
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_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
|
||||||
@@ -68,7 +79,7 @@ class CryptoTailStrategyController(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("创建尾盘策略异常: ${e.message}", e)
|
logger.error("创建加密价差策略异常: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +94,7 @@ class CryptoTailStrategyController(
|
|||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("更新尾盘策略失败: ${e.message}", e)
|
logger.error("更新加密价差策略失败: ${e.message}", e)
|
||||||
val code = when (e.message) {
|
val code = when (e.message) {
|
||||||
ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
|
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_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
||||||
@@ -95,7 +106,7 @@ class CryptoTailStrategyController(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("更新尾盘策略异常: ${e.message}", e)
|
logger.error("更新加密价差策略异常: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,16 +122,36 @@ class CryptoTailStrategyController(
|
|||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("删除尾盘策略失败: ${e.message}", e)
|
logger.error("删除加密价差策略失败: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("删除尾盘策略异常: ${e.message}", e)
|
logger.error("删除加密价差策略异常: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
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")
|
@PostMapping("/triggers")
|
||||||
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
|
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
|
||||||
return try {
|
return try {
|
||||||
@@ -146,7 +177,13 @@ class CryptoTailStrategyController(
|
|||||||
return try {
|
return try {
|
||||||
val options = listOf(
|
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-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 = "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))
|
ResponseEntity.ok(ApiResponse.success(options))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -167,8 +204,10 @@ class CryptoTailStrategyController(
|
|||||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource))
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource))
|
||||||
}
|
}
|
||||||
val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong()
|
val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong()
|
||||||
?: (System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds
|
?: ((System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds)
|
||||||
val pair = binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix)
|
// 默认使用 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))
|
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "fetch_failed", messageSource))
|
||||||
val body = CryptoTailAutoMinSpreadResponse(
|
val body = CryptoTailAutoMinSpreadResponse(
|
||||||
minSpreadUp = pair.first.toPlainString(),
|
minSpreadUp = pair.first.toPlainString(),
|
||||||
@@ -180,4 +219,65 @@ class CryptoTailStrategyController(
|
|||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化加密价差策略监控
|
||||||
|
* 返回策略信息、开盘价、tokenIds等初始化数据
|
||||||
|
*/
|
||||||
|
@PostMapping("/monitor/init")
|
||||||
|
fun initMonitor(@RequestBody request: CryptoTailMonitorInitRequest): ResponseEntity<ApiResponse<CryptoTailMonitorInitResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.strategyId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val result = cryptoTailMonitorService.initMonitor(request)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("初始化加密价差策略监控失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("初始化加密价差策略监控异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动下单
|
||||||
|
* 用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单
|
||||||
|
*/
|
||||||
|
@PostMapping("/manual-order")
|
||||||
|
fun manualOrder(@RequestBody request: CryptoTailManualOrderRequest): ResponseEntity<ApiResponse<CryptoTailManualOrderResponse>> {
|
||||||
|
return runBlocking {
|
||||||
|
try {
|
||||||
|
if (request.strategyId <= 0) {
|
||||||
|
return@runBlocking ResponseEntity.ok(
|
||||||
|
ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val result = cryptoTailStrategyExecutionService.manualOrder(request)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("手动下单失败: ${e.message}", e)
|
||||||
|
val code = when (e.message) {
|
||||||
|
"策略不存在" -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
|
||||||
|
"当前周期已下单" -> ErrorCode.PARAM_ERROR
|
||||||
|
"价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR
|
||||||
|
"数量不能少于 1" -> ErrorCode.PARAM_ERROR
|
||||||
|
"总金额不能少于 1 USDC" -> ErrorCode.PARAM_ERROR
|
||||||
|
"总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR
|
||||||
|
else -> ErrorCode.SERVER_ERROR
|
||||||
|
}
|
||||||
|
ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("手动下单异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+166
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.system
|
|||||||
import com.wrbug.polymarketbot.dto.*
|
import com.wrbug.polymarketbot.dto.*
|
||||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||||
import com.wrbug.polymarketbot.service.system.NotificationConfigService
|
import com.wrbug.polymarketbot.service.system.NotificationConfigService
|
||||||
|
import com.wrbug.polymarketbot.service.system.NotificationTemplateService
|
||||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
@@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.*
|
|||||||
class NotificationController(
|
class NotificationController(
|
||||||
private val notificationConfigService: NotificationConfigService,
|
private val notificationConfigService: NotificationConfigService,
|
||||||
private val telegramNotificationService: TelegramNotificationService,
|
private val telegramNotificationService: TelegramNotificationService,
|
||||||
|
private val notificationTemplateService: NotificationTemplateService,
|
||||||
private val messageSource: MessageSource
|
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
|
val id: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板详情请求
|
||||||
|
*/
|
||||||
|
data class TemplateDetailRequest(
|
||||||
|
val templateType: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模板请求(带类型)
|
||||||
|
*/
|
||||||
|
data class UpdateTemplateRequestWithId(
|
||||||
|
val templateType: String,
|
||||||
|
val templateContent: String
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ data class SystemConfigDto(
|
|||||||
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
||||||
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
||||||
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
|
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
|
||||||
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示)
|
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(完整,用于前端展示)
|
||||||
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示)
|
val builderSecretDisplay: String? = null, // Builder Secret 显示值(完整,用于前端展示)
|
||||||
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示)
|
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(完整,用于前端展示)
|
||||||
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
|
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 // 设置状态检查结果(可选)
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.wrbug.polymarketbot.dto
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略创建请求
|
* 加密价差策略创建请求
|
||||||
* 金额与价格使用 String,后端转为 BigDecimal
|
* 金额与价格使用 String,后端转为 BigDecimal
|
||||||
*/
|
*/
|
||||||
data class CryptoTailStrategyCreateRequest(
|
data class CryptoTailStrategyCreateRequest(
|
||||||
@@ -15,13 +15,17 @@ data class CryptoTailStrategyCreateRequest(
|
|||||||
val maxPrice: String? = null,
|
val maxPrice: String? = null,
|
||||||
val amountMode: String = "RATIO",
|
val amountMode: String = "RATIO",
|
||||||
val amountValue: String = "0",
|
val amountValue: String = "0",
|
||||||
val minSpreadMode: String = "NONE",
|
/** 价差模式: NONE, FIXED, AUTO */
|
||||||
val minSpreadValue: String? = null,
|
val spreadMode: String = "NONE",
|
||||||
|
/** 价差数值 */
|
||||||
|
val spreadValue: String? = null,
|
||||||
|
/** 价差方向: MIN=最小价差, MAX=最大价差 */
|
||||||
|
val spreadDirection: String = "MIN",
|
||||||
val enabled: Boolean = true
|
val enabled: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略更新请求
|
* 加密价差策略更新请求
|
||||||
*/
|
*/
|
||||||
data class CryptoTailStrategyUpdateRequest(
|
data class CryptoTailStrategyUpdateRequest(
|
||||||
val strategyId: Long = 0L,
|
val strategyId: Long = 0L,
|
||||||
@@ -32,13 +36,17 @@ data class CryptoTailStrategyUpdateRequest(
|
|||||||
val maxPrice: String? = null,
|
val maxPrice: String? = null,
|
||||||
val amountMode: String? = null,
|
val amountMode: String? = null,
|
||||||
val amountValue: String? = null,
|
val amountValue: String? = null,
|
||||||
val minSpreadMode: String? = null,
|
/** 价差模式: NONE, FIXED, AUTO */
|
||||||
val minSpreadValue: String? = null,
|
val spreadMode: String? = null,
|
||||||
|
/** 价差数值 */
|
||||||
|
val spreadValue: String? = null,
|
||||||
|
/** 价差方向: MIN=最小价差, MAX=最大价差 */
|
||||||
|
val spreadDirection: String? = null,
|
||||||
val enabled: Boolean? = null
|
val enabled: Boolean? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略列表请求
|
* 加密价差策略列表请求
|
||||||
*/
|
*/
|
||||||
data class CryptoTailStrategyListRequest(
|
data class CryptoTailStrategyListRequest(
|
||||||
val accountId: Long? = null,
|
val accountId: Long? = null,
|
||||||
@@ -46,7 +54,7 @@ data class CryptoTailStrategyListRequest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略 DTO(列表与详情)
|
* 加密价差策略 DTO(列表与详情)
|
||||||
*/
|
*/
|
||||||
data class CryptoTailStrategyDto(
|
data class CryptoTailStrategyDto(
|
||||||
val id: Long = 0L,
|
val id: Long = 0L,
|
||||||
@@ -61,8 +69,12 @@ data class CryptoTailStrategyDto(
|
|||||||
val maxPrice: String = "1",
|
val maxPrice: String = "1",
|
||||||
val amountMode: String = "RATIO",
|
val amountMode: String = "RATIO",
|
||||||
val amountValue: String = "0",
|
val amountValue: String = "0",
|
||||||
val minSpreadMode: String = "NONE",
|
/** 价差模式: NONE, FIXED, AUTO */
|
||||||
val minSpreadValue: String? = null,
|
val spreadMode: String = "NONE",
|
||||||
|
/** 价差数值 */
|
||||||
|
val spreadValue: String? = null,
|
||||||
|
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
|
||||||
|
val spreadDirection: String = "MIN",
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
val lastTriggerAt: Long? = null,
|
val lastTriggerAt: Long? = null,
|
||||||
/** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */
|
/** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */
|
||||||
@@ -78,14 +90,14 @@ data class CryptoTailStrategyDto(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略列表响应
|
* 加密价差策略列表响应
|
||||||
*/
|
*/
|
||||||
data class CryptoTailStrategyListResponse(
|
data class CryptoTailStrategyListResponse(
|
||||||
val list: List<CryptoTailStrategyDto> = emptyList()
|
val list: List<CryptoTailStrategyDto> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略删除请求
|
* 加密价差策略删除请求
|
||||||
*/
|
*/
|
||||||
data class CryptoTailStrategyDeleteRequest(
|
data class CryptoTailStrategyDeleteRequest(
|
||||||
val strategyId: Long = 0L
|
val strategyId: Long = 0L
|
||||||
@@ -138,7 +150,7 @@ data class CryptoTailStrategyTriggerListResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动最小价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7)
|
* 自动价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7)
|
||||||
*/
|
*/
|
||||||
data class CryptoTailAutoMinSpreadResponse(
|
data class CryptoTailAutoMinSpreadResponse(
|
||||||
val minSpreadUp: String = "0",
|
val minSpreadUp: String = "0",
|
||||||
@@ -155,3 +167,45 @@ data class CryptoTailMarketOptionDto(
|
|||||||
val periodStartUnix: Long = 0L,
|
val periodStartUnix: Long = 0L,
|
||||||
val endDate: String? = null
|
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,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 获取)
|
* 订单详情(通过 API 获取)
|
||||||
|
* @param price 订单限价(用户提交的买入/卖出价)
|
||||||
|
* @param avgFilledPrice 实际成交价 = original_size * price / size_matched(有成交时优先用于推送展示)
|
||||||
*/
|
*/
|
||||||
data class OrderDetailDto(
|
data class OrderDetailDto(
|
||||||
val id: String, // 订单 ID
|
val id: String, // 订单 ID
|
||||||
val market: String, // 市场 ID (condition ID)
|
val market: String, // 市场 ID (condition ID)
|
||||||
val side: String, // BUY/SELL
|
val side: String, // BUY/SELL
|
||||||
val price: String, // 价格
|
val price: String, // 订单限价
|
||||||
val size: String, // 订单大小
|
val size: String, // 订单大小
|
||||||
val filled: String, // 已成交数量
|
val filled: String, // 已成交数量
|
||||||
val status: String, // 订单状态
|
val status: String, // 订单状态
|
||||||
val createdAt: String, // 创建时间(ISO 8601 格式)
|
val createdAt: String, // 创建时间(ISO 8601 格式)
|
||||||
val marketName: String? = null, // 市场名称(通过 Data API 获取)
|
val marketName: String? = null, // 市场名称(通过 Data API 获取)
|
||||||
val marketSlug: String? = null, // 市场 slug
|
val marketSlug: String? = null, // 市场 slug
|
||||||
val marketIcon: String? = null // 市场图标
|
val marketIcon: String? = null, // 市场图标
|
||||||
|
val avgFilledPrice: String? = null // 实际成交价 = original_size*price/size_matched(有成交时使用)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package com.wrbug.polymarketbot.entity
|
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 jakarta.persistence.*
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加密市场尾盘策略实体
|
* 加密价差策略实体
|
||||||
* 5/15 分钟 Up or Down 市场,在周期内时间窗口、价格进入区间时市价买入
|
* 5/15 分钟 Up or Down 市场,在周期内时间窗口、价格进入区间时市价买入
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@@ -45,11 +48,19 @@ data class CryptoTailStrategy(
|
|||||||
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
|
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
|
||||||
val amountValue: BigDecimal = BigDecimal.ZERO,
|
val amountValue: BigDecimal = BigDecimal.ZERO,
|
||||||
|
|
||||||
@Column(name = "min_spread_mode", nullable = false, length = 16)
|
/** 价差模式: NONE=不校验, FIXED=固定值, AUTO=历史计算 */
|
||||||
val minSpreadMode: String = "NONE",
|
@Convert(converter = SpreadModeConverter::class)
|
||||||
|
@Column(name = "spread_mode", nullable = false, columnDefinition = "TINYINT")
|
||||||
|
val spreadMode: SpreadMode = SpreadMode.NONE,
|
||||||
|
|
||||||
@Column(name = "min_spread_value", precision = 20, scale = 8)
|
/** 价差数值(FIXED 时必填;AUTO 时可存计算值) */
|
||||||
val minSpreadValue: BigDecimal? = null,
|
@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)
|
@Column(name = "enabled", nullable = false)
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import java.math.BigDecimal
|
|||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略触发记录
|
* 加密价差策略触发记录
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "crypto_tail_strategy_trigger")
|
@Table(name = "crypto_tail_strategy_trigger")
|
||||||
@@ -56,6 +56,9 @@ data class CryptoTailStrategyTrigger(
|
|||||||
@Column(name = "fail_reason", length = 500)
|
@Column(name = "fail_reason", length = 500)
|
||||||
val failReason: String? = null,
|
val failReason: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "trigger_type", nullable = false, length = 20)
|
||||||
|
val triggerType: String = "AUTO",
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +158,8 @@ enum class ErrorCode(
|
|||||||
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
|
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
|
||||||
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
||||||
|
|
||||||
// 尾盘策略 (4710-4729)
|
// 加密价差策略 (4710-4729)
|
||||||
CRYPTO_TAIL_STRATEGY_NOT_FOUND(4710, "尾盘策略不存在", "error.crypto_tail_strategy_not_found"),
|
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_INVALID(4711, "时间区间开始不能大于结束", "error.crypto_tail_strategy_window_invalid"),
|
||||||
CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED(4712, "时间区间不能超过周期长度", "error.crypto_tail_strategy_window_exceed"),
|
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_INTERVAL_INVALID(4713, "周期仅支持 300 或 900 秒", "error.crypto_tail_strategy_interval_invalid"),
|
||||||
@@ -259,11 +259,11 @@ enum class ErrorCode(
|
|||||||
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_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)
|
// 加密价差策略服务 (5620-5629)
|
||||||
SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED(5620, "创建尾盘策略失败", "error.server.crypto_tail_strategy_create_failed"),
|
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_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_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_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");
|
SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed");
|
||||||
|
|
||||||
companion object {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -3,6 +3,6 @@ package com.wrbug.polymarketbot.event
|
|||||||
import org.springframework.context.ApplicationEvent
|
import org.springframework.context.ApplicationEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。
|
* 加密价差策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。
|
||||||
*/
|
*/
|
||||||
class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
|
class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
|
||||||
|
|||||||
+13
-1
@@ -23,7 +23,7 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy
|
|||||||
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
|
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
|
||||||
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
|
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
|
||||||
|
|
||||||
/** 根据订单 ID 查询尾盘触发记录 */
|
/** 根据订单 ID 查询加密价差策略触发记录 */
|
||||||
fun findByOrderId(orderId: String): CryptoTailStrategyTrigger?
|
fun findByOrderId(orderId: String): CryptoTailStrategyTrigger?
|
||||||
|
|
||||||
/** 轮询发 TG:status=success、orderId 非空、未发过通知,按创建时间正序 */
|
/** 轮询发 TG:status=success、orderId 非空、未发过通知,按创建时间正序 */
|
||||||
@@ -40,4 +40,16 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy
|
|||||||
/** 策略已结算中赢的笔数(outcome_index = winner_outcome_index) */
|
/** 策略已结算中赢的笔数(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")
|
@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
|
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
|
||||||
|
}
|
||||||
+360
-35
@@ -10,7 +10,10 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
|||||||
import com.wrbug.polymarketbot.util.eq
|
import com.wrbug.polymarketbot.util.eq
|
||||||
import com.wrbug.polymarketbot.util.gt
|
import com.wrbug.polymarketbot.util.gt
|
||||||
import com.wrbug.polymarketbot.util.JsonUtils
|
import com.wrbug.polymarketbot.util.JsonUtils
|
||||||
|
import com.wrbug.polymarketbot.util.fromJson
|
||||||
import com.wrbug.polymarketbot.util.getEventSlug
|
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.PolymarketClobService
|
||||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||||
import com.wrbug.polymarketbot.service.common.MarketService
|
import com.wrbug.polymarketbot.service.common.MarketService
|
||||||
@@ -125,8 +128,8 @@ class AccountService(
|
|||||||
|
|
||||||
// 7. 加密敏感信息
|
// 7. 加密敏感信息
|
||||||
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
||||||
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
|
val encryptedApiSecret = apiKeyCreds.secret.let { cryptoUtils.encrypt(it) }
|
||||||
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
|
val encryptedApiPassphrase = apiKeyCreds.passphrase.let { cryptoUtils.encrypt(it) }
|
||||||
|
|
||||||
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
|
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
|
||||||
val accountName = if (request.accountName.isNullOrBlank()) {
|
val accountName = if (request.accountName.isNullOrBlank()) {
|
||||||
@@ -361,6 +364,215 @@ class AccountService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket 代币批准检查:USDC.e 需授权的 spender 合约地址(Polygon 主网)
|
||||||
|
* 来源:Polymarket/magic-safe-builder-example README §6 Token Approvals
|
||||||
|
* 及 neg-risk-ctf-adapter 仓库 addresses.json (chainId 137)
|
||||||
|
*/
|
||||||
|
private val setupApprovalSpenders = mapOf(
|
||||||
|
"CTF_CONTRACT" to "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", // Conditional Tokens
|
||||||
|
"CTF_EXCHANGE" to "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E", // 普通市场交易所
|
||||||
|
"NEG_RISK_EXCHANGE" to "0xC5d563A36AE78145C45a50134d48A1215220f80a", // 负风险市场交易所
|
||||||
|
"NEG_RISK_ADAPTER" to "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" // 负风险适配器(非 WCOL 地址)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** USDC 精度(6 位小数) */
|
||||||
|
private val usdcDecimals = java.math.BigDecimal("1000000")
|
||||||
|
|
||||||
|
/** ERC20 无限授权额度(type(uint256).max),Polymarket 默认使用无限授权 */
|
||||||
|
private val unlimitedAllowance = BigInteger("115792089237316195423570985008687907853269984665640564039457584007913129639935")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账户设置状态(代理部署、交易启用、代币批准)
|
||||||
|
* @param accountId 账户 ID
|
||||||
|
* @return AccountSetupStatusDto
|
||||||
|
*/
|
||||||
|
suspend fun checkAccountSetupStatus(accountId: Long): Result<AccountSetupStatusDto> {
|
||||||
|
return try {
|
||||||
|
if (accountId <= 0) {
|
||||||
|
return Result.failure(IllegalArgumentException("账户 ID 无效"))
|
||||||
|
}
|
||||||
|
val account = accountRepository.findById(accountId).orElse(null)
|
||||||
|
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||||
|
|
||||||
|
val proxyAddress = account.proxyAddress
|
||||||
|
if (proxyAddress.isBlank()) {
|
||||||
|
return Result.success(
|
||||||
|
AccountSetupStatusDto(
|
||||||
|
proxyDeployed = false,
|
||||||
|
tradingEnabled = account.apiKey != null && account.apiSecret != null && account.apiPassphrase != null,
|
||||||
|
tokensApproved = false,
|
||||||
|
approvalDetails = null,
|
||||||
|
error = "代理地址为空"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤1:代理钱包是否已部署
|
||||||
|
val proxyDeployed = blockchainService.isProxyDeployed(proxyAddress)
|
||||||
|
|
||||||
|
// 步骤2:交易是否已启用(API 凭证是否已配置)
|
||||||
|
val tradingEnabled = account.apiKey != null &&
|
||||||
|
account.apiSecret != null &&
|
||||||
|
account.apiPassphrase != null
|
||||||
|
|
||||||
|
// 步骤3:代币是否已批准(USDC 对各 spender 的 allowance,默认无限授权)
|
||||||
|
val approvalDetails = mutableMapOf<String, String>()
|
||||||
|
var tokensApproved = true
|
||||||
|
for ((name, spender) in setupApprovalSpenders) {
|
||||||
|
val allowanceResult = blockchainService.getUsdcAllowance(proxyAddress, spender)
|
||||||
|
val allowance = allowanceResult.getOrNull() ?: BigInteger.ZERO
|
||||||
|
val displayAmount = if (allowance >= unlimitedAllowance) {
|
||||||
|
"unlimited"
|
||||||
|
} else {
|
||||||
|
java.math.BigDecimal(allowance).divide(usdcDecimals, 6, java.math.RoundingMode.DOWN).toPlainString()
|
||||||
|
}
|
||||||
|
approvalDetails[name] = displayAmount
|
||||||
|
if (allowance <= BigInteger.ZERO) {
|
||||||
|
tokensApproved = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(
|
||||||
|
AccountSetupStatusDto(
|
||||||
|
proxyDeployed = proxyDeployed,
|
||||||
|
tradingEnabled = tradingEnabled,
|
||||||
|
tokensApproved = tokensApproved,
|
||||||
|
approvalDetails = approvalDetails,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("检查账户设置状态失败: accountId=$accountId, ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 步骤1 跳转 URL(代理部署需在 Polymarket 完成) */
|
||||||
|
private val setupStep1RedirectUrl = "https://polymarket.com/settings/wallet"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行设置步骤(由后端实现或返回跳转)
|
||||||
|
* 步骤1:仅返回跳转 URL,由用户前往 Polymarket 完成部署
|
||||||
|
* 步骤2:创建/派生 API Key 并更新账户
|
||||||
|
* 步骤3:通过代理钱包批量执行 USDC 授权
|
||||||
|
*/
|
||||||
|
suspend fun executeSetupStep(accountId: Long, step: Int): Result<ExecuteSetupStepResponse> {
|
||||||
|
return try {
|
||||||
|
if (accountId <= 0) {
|
||||||
|
return Result.failure(IllegalArgumentException("账户 ID 无效"))
|
||||||
|
}
|
||||||
|
val account = accountRepository.findById(accountId).orElse(null)
|
||||||
|
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||||
|
|
||||||
|
when (step) {
|
||||||
|
1 -> {
|
||||||
|
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.MAGIC)
|
||||||
|
if (walletType == WalletType.MAGIC) {
|
||||||
|
Result.success(
|
||||||
|
ExecuteSetupStepResponse(
|
||||||
|
success = false,
|
||||||
|
redirectUrl = setupStep1RedirectUrl
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val proxyAddress = account.proxyAddress
|
||||||
|
if (proxyAddress.isBlank()) {
|
||||||
|
return Result.failure(IllegalArgumentException("代理地址为空"))
|
||||||
|
}
|
||||||
|
val alreadyDeployed = blockchainService.isProxyDeployed(proxyAddress)
|
||||||
|
if (alreadyDeployed) {
|
||||||
|
Result.success(ExecuteSetupStepResponse(success = true))
|
||||||
|
} else {
|
||||||
|
val privateKey = decryptPrivateKey(account)
|
||||||
|
val deployResult = relayClientService.deploySafeViaBuilderRelayer(
|
||||||
|
privateKey = privateKey,
|
||||||
|
proxyAddress = proxyAddress,
|
||||||
|
fromAddress = account.walletAddress
|
||||||
|
)
|
||||||
|
deployResult.fold(
|
||||||
|
onSuccess = { txHash ->
|
||||||
|
Result.success(
|
||||||
|
ExecuteSetupStepResponse(
|
||||||
|
success = true,
|
||||||
|
transactionHash = txHash
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("Safe 部署失败: accountId=$accountId, ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
val privateKey = decryptPrivateKey(account)
|
||||||
|
val result = apiKeyService.createOrDeriveApiKey(
|
||||||
|
privateKey = privateKey,
|
||||||
|
walletAddress = account.walletAddress,
|
||||||
|
chainId = 137L
|
||||||
|
)
|
||||||
|
if (result.isFailure) {
|
||||||
|
val e = result.exceptionOrNull()
|
||||||
|
logger.error("启用交易(API Key)失败: accountId=$accountId, ${e?.message}", e)
|
||||||
|
return Result.failure(e ?: IllegalStateException("获取 API Key 失败"))
|
||||||
|
}
|
||||||
|
val creds = result.getOrNull()
|
||||||
|
?: return Result.failure(IllegalStateException("API Key 返回为空"))
|
||||||
|
val encryptedSecret = creds.secret.let { cryptoUtils.encrypt(it) }
|
||||||
|
val encryptedPassphrase = creds.passphrase.let { cryptoUtils.encrypt(it) }
|
||||||
|
val updated = account.copy(
|
||||||
|
apiKey = creds.apiKey,
|
||||||
|
apiSecret = encryptedSecret,
|
||||||
|
apiPassphrase = encryptedPassphrase,
|
||||||
|
updatedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
accountRepository.save(updated)
|
||||||
|
orderPushService.refreshSubscriptions()
|
||||||
|
Result.success(ExecuteSetupStepResponse(success = true))
|
||||||
|
}
|
||||||
|
3 -> {
|
||||||
|
val proxyAddress = account.proxyAddress
|
||||||
|
if (proxyAddress.isBlank()) {
|
||||||
|
return Result.failure(IllegalArgumentException("代理地址为空,请先完成步骤1"))
|
||||||
|
}
|
||||||
|
val privateKey = decryptPrivateKey(account)
|
||||||
|
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||||
|
val approveTxs = setupApprovalSpenders.values.map { spender ->
|
||||||
|
relayClientService.createUsdcApproveTx(spender, unlimitedAllowance)
|
||||||
|
}
|
||||||
|
val multiSendTx = relayClientService.createMultiSendTx(approveTxs)
|
||||||
|
val executeResult = relayClientService.execute(
|
||||||
|
privateKey = privateKey,
|
||||||
|
proxyAddress = proxyAddress,
|
||||||
|
safeTx = multiSendTx,
|
||||||
|
walletType = walletType
|
||||||
|
)
|
||||||
|
executeResult.fold(
|
||||||
|
onSuccess = { txHash ->
|
||||||
|
Result.success(
|
||||||
|
ExecuteSetupStepResponse(
|
||||||
|
success = true,
|
||||||
|
transactionHash = txHash
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("代币授权执行失败: accountId=$accountId, ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Result.failure(IllegalArgumentException("无效的步骤: $step,应为 1、2 或 3"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("执行设置步骤失败: accountId=$accountId, step=$step, ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新账户信息
|
* 更新账户信息
|
||||||
*/
|
*/
|
||||||
@@ -727,7 +939,38 @@ class AccountService(
|
|||||||
throw RuntimeException("解密私钥失败: ${e.message}", e)
|
throw RuntimeException("解密私钥失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包为 USDC.e。
|
||||||
|
* 由 WcolUnwrapJobService 每 20 秒调用,赎回后无需在赎回流程内等待确认与解包。
|
||||||
|
*/
|
||||||
|
suspend fun runWcolUnwrapForAllAccounts() {
|
||||||
|
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
|
||||||
|
if (accounts.isEmpty()) return
|
||||||
|
for (account in accounts) {
|
||||||
|
try {
|
||||||
|
val privateKey = decryptPrivateKey(account)
|
||||||
|
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||||
|
blockchainService.unwrapWcolForProxy(
|
||||||
|
privateKey = privateKey,
|
||||||
|
proxyAddress = account.proxyAddress,
|
||||||
|
walletType = walletType
|
||||||
|
).fold(
|
||||||
|
onSuccess = { txHash ->
|
||||||
|
if (txHash != null) {
|
||||||
|
logger.info("轮询解包 WCOL: accountId=${account.id}, proxy=${account.proxyAddress.take(10)}..., txHash=$txHash")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.warn("轮询解包 WCOL 失败 accountId=${account.id}: ${e.message}")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("轮询解包 WCOL 跳过 accountId=${account.id}: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解密账户 API Secret
|
* 解密账户 API Secret
|
||||||
*/
|
*/
|
||||||
@@ -888,7 +1131,7 @@ class AccountService(
|
|||||||
|
|
||||||
// 3. 验证仓位是否存在并获取原始数量
|
// 3. 验证仓位是否存在并获取原始数量
|
||||||
val positionsResult = getAllPositions()
|
val positionsResult = getAllPositions()
|
||||||
val (position, originalQuantity) = positionsResult.fold(
|
val (_, originalQuantity) = positionsResult.fold(
|
||||||
onSuccess = { positionListResponse ->
|
onSuccess = { positionListResponse ->
|
||||||
val position = positionListResponse.currentPositions.find {
|
val position = positionListResponse.currentPositions.find {
|
||||||
it.accountId == request.accountId &&
|
it.accountId == request.accountId &&
|
||||||
@@ -921,7 +1164,7 @@ class AccountService(
|
|||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
return Result.failure(Exception("查询仓位失败: ${e.message}"))
|
return Result.failure(Exception("查询仓位失败: ${e.message}"))
|
||||||
}
|
}
|
||||||
) ?: return Result.failure(IllegalArgumentException("仓位不存在"))
|
)
|
||||||
|
|
||||||
// 4. 计算实际卖出数量
|
// 4. 计算实际卖出数量
|
||||||
val sellQuantity = if (percentDecimal != null) {
|
val sellQuantity = if (percentDecimal != null) {
|
||||||
@@ -1040,7 +1283,7 @@ class AccountService(
|
|||||||
|
|
||||||
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
|
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
|
||||||
order = signedOrder,
|
order = signedOrder,
|
||||||
owner = account.apiKey!!, // API Key
|
owner = account.apiKey, // API Key
|
||||||
orderType = orderType,
|
orderType = orderType,
|
||||||
deferExec = false
|
deferExec = false
|
||||||
)
|
)
|
||||||
@@ -1060,7 +1303,7 @@ class AccountService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val clobApi = retrofitFactory.createClobApi(
|
val clobApi = retrofitFactory.createClobApi(
|
||||||
account.apiKey!!,
|
account.apiKey,
|
||||||
apiSecret,
|
apiSecret,
|
||||||
apiPassphrase,
|
apiPassphrase,
|
||||||
account.walletAddress
|
account.walletAddress
|
||||||
@@ -1091,13 +1334,22 @@ class AccountService(
|
|||||||
|
|
||||||
// 使用当前时间作为订单创建时间
|
// 使用当前时间作为订单创建时间
|
||||||
val orderTime = System.currentTimeMillis()
|
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(
|
telegramNotificationService?.sendOrderSuccessNotification(
|
||||||
orderId = orderId,
|
orderId = orderId,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketId = request.marketId,
|
marketId = request.marketId,
|
||||||
marketSlug = marketSlug,
|
marketSlug = marketSlug,
|
||||||
side = request.side,
|
side = "SELL", // 手动卖出订单,方向固定为 SELL
|
||||||
|
outcome = request.side, // request.side 是市场方向(YES/NO)
|
||||||
price = sellPrice, // 直接传递卖出价格
|
price = sellPrice, // 直接传递卖出价格
|
||||||
size = sellQuantity.toPlainString(), // 直接传递卖出数量
|
size = sellQuantity.toPlainString(), // 直接传递卖出数量
|
||||||
accountName = account.accountName,
|
accountName = account.accountName,
|
||||||
@@ -1108,7 +1360,8 @@ class AccountService(
|
|||||||
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
|
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
|
||||||
walletAddressForApi = account.walletAddress,
|
walletAddressForApi = account.walletAddress,
|
||||||
locale = locale,
|
locale = locale,
|
||||||
orderTime = orderTime // 使用订单创建时间
|
orderTime = orderTime, // 使用订单创建时间
|
||||||
|
availableBalance = availableBalance
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("发送订单成功通知失败: ${e.message}", e)
|
logger.warn("发送订单成功通知失败: ${e.message}", e)
|
||||||
@@ -1128,7 +1381,7 @@ class AccountService(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} 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"
|
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)
|
logger.error(fullErrorMsg)
|
||||||
|
|
||||||
@@ -1173,6 +1426,14 @@ class AccountService(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
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 ""}"
|
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)
|
logger.error(fullErrorMsg)
|
||||||
|
|
||||||
@@ -1191,8 +1452,10 @@ class AccountService(
|
|||||||
java.util.Locale("zh", "CN") // 默认简体中文
|
java.util.Locale("zh", "CN") // 默认简体中文
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只传递后端返回的 msg,不传递完整堆栈
|
// 优先使用解析的 API error,其次使用响应体的 errorMsg,最后使用默认消息
|
||||||
val errorMsg = orderResponse.body()?.errorMsg ?: "创建订单失败"
|
val errorMsg = apiError
|
||||||
|
?: orderResponse.body()?.getErrorMessage()
|
||||||
|
?: "创建订单失败 (HTTP ${orderResponse.code()})"
|
||||||
|
|
||||||
telegramNotificationService?.sendOrderFailureNotification(
|
telegramNotificationService?.sendOrderFailureNotification(
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
@@ -1202,7 +1465,7 @@ class AccountService(
|
|||||||
outcome = null, // 失败时可能没有 outcome
|
outcome = null, // 失败时可能没有 outcome
|
||||||
price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET",
|
price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET",
|
||||||
size = sellQuantity.toString(),
|
size = sellQuantity.toString(),
|
||||||
errorMessage = errorMsg, // 只传递后端返回的 msg
|
errorMessage = errorMsg, // 只传递后端返回的错误信息
|
||||||
accountName = account.accountName,
|
accountName = account.accountName,
|
||||||
walletAddress = account.walletAddress,
|
walletAddress = account.walletAddress,
|
||||||
locale = locale
|
locale = locale
|
||||||
@@ -1464,21 +1727,30 @@ class AccountService(
|
|||||||
// 按市场分组(同一市场的仓位可以批量赎回)
|
// 按市场分组(同一市场的仓位可以批量赎回)
|
||||||
val positionsByMarket = positions.groupBy { it.first.marketId }
|
val positionsByMarket = positions.groupBy { it.first.marketId }
|
||||||
|
|
||||||
// 对每个市场执行赎回
|
// 获取钱包类型
|
||||||
|
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||||
|
|
||||||
|
// 解密私钥(只需解密一次)
|
||||||
|
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||||
|
|
||||||
|
// 执行赎回
|
||||||
var lastTxHash: String? = null
|
var lastTxHash: String? = null
|
||||||
for ((marketId, marketPositions) in positionsByMarket) {
|
|
||||||
val indexSets = marketPositions.map { it.second }
|
|
||||||
|
|
||||||
// 解密私钥
|
// Safe 钱包且有多个市场:使用 MultiSend 批量赎回
|
||||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
// 调用区块链服务赎回仓位
|
logger.info("账户 $accountId: 使用 MultiSend 批量赎回 ${redeemRequests.size} 个市场")
|
||||||
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
|
||||||
val redeemResult = blockchainService.redeemPositions(
|
val redeemResult = blockchainService.redeemPositionsBatch(
|
||||||
privateKey = decryptedPrivateKey,
|
privateKey = decryptedPrivateKey,
|
||||||
proxyAddress = account.proxyAddress,
|
proxyAddress = account.proxyAddress,
|
||||||
conditionId = marketId,
|
redeemRequests = redeemRequests,
|
||||||
indexSets = indexSets,
|
|
||||||
walletType = walletTypeEnum
|
walletType = walletTypeEnum
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1487,11 +1759,38 @@ class AccountService(
|
|||||||
lastTxHash = txHash
|
lastTxHash = txHash
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
|
logger.error("账户 $accountId MultiSend 批量赎回失败: ${e.message}", e)
|
||||||
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
|
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 ->
|
val accountTotalValue = redeemedInfo.fold(BigDecimal.ZERO) { sum, info ->
|
||||||
@@ -1524,16 +1823,42 @@ class AccountService(
|
|||||||
for (transaction in accountTransactions) {
|
for (transaction in accountTransactions) {
|
||||||
val account = accounts[transaction.accountId]
|
val account = accounts[transaction.accountId]
|
||||||
if (account != null) {
|
if (account != null) {
|
||||||
telegramNotificationService?.sendRedeemNotification(
|
// 查询可用余额
|
||||||
accountName = account.accountName,
|
val availableBalance = try {
|
||||||
walletAddress = account.walletAddress,
|
blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull()
|
||||||
transactionHash = transaction.transactionHash,
|
} catch (e: Exception) {
|
||||||
totalRedeemedValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info ->
|
logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}")
|
||||||
sum.add(info.value.toSafeBigDecimal())
|
null
|
||||||
}.toPlainString(),
|
}
|
||||||
positions = transaction.positions,
|
|
||||||
locale = locale
|
// 计算该账户的赎回总价值
|
||||||
)
|
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) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
+24
-6
@@ -25,6 +25,7 @@ import com.wrbug.polymarketbot.service.common.MarketPriceService
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仓位检查服务
|
* 仓位检查服务
|
||||||
@@ -77,7 +78,10 @@ class PositionCheckService(
|
|||||||
|
|
||||||
// 同步锁,确保订阅任务的启动和停止是线程安全的
|
// 同步锁,确保订阅任务的启动和停止是线程安全的
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
|
|
||||||
|
// 防止 checkRedeemablePositions 重入:上一轮检查未完成时,新一轮轮询直接跳过
|
||||||
|
private val redeemCheckInProgress = AtomicBoolean(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务)
|
* 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务)
|
||||||
*/
|
*/
|
||||||
@@ -328,18 +332,23 @@ class PositionCheckService(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 逻辑1:处理待赎回仓位
|
* 逻辑1:处理待赎回仓位
|
||||||
https://clob.polymarket.com * 按照以下逻辑处理:
|
* 按照以下逻辑处理:
|
||||||
* 1. 无待赎回仓位:跳过
|
* 1. 无待赎回仓位:跳过
|
||||||
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
|
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
|
||||||
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
||||||
|
* 防重入:上一轮检查未完成时,本轮直接跳过,避免并发赎回。
|
||||||
*/
|
*/
|
||||||
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
|
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
|
||||||
|
if (!redeemCheckInProgress.compareAndSet(false, true)) {
|
||||||
|
logger.debug("跳过本次待赎回仓位检查:上一次检查尚未完成")
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// 1. 无待赎回仓位:跳过
|
// 1. 无待赎回仓位:跳过
|
||||||
if (redeemablePositions.isEmpty()) {
|
if (redeemablePositions.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查系统级别的自动赎回配置
|
// 检查系统级别的自动赎回配置
|
||||||
val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled()
|
val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled()
|
||||||
val apiKeyConfigured = relayClientService.isBuilderApiKeyConfigured()
|
val apiKeyConfigured = relayClientService.isBuilderApiKeyConfigured()
|
||||||
@@ -373,14 +382,21 @@ class PositionCheckService(
|
|||||||
}
|
}
|
||||||
return // 未配置时直接返回,不进行后续处理
|
return // 未配置时直接返回,不进行后续处理
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Builder Relayer 配额冷却期内不再发起赎回(如 API 返回 quota exceeded, resets in N seconds)
|
||||||
|
if (relayClientService.isBuilderRelayerQuotaBlocked()) {
|
||||||
|
val remaining = relayClientService.getBuilderRelayerQuotaBlockedRemainingSeconds()
|
||||||
|
logger.info("Builder Relayer 配额冷却中,跳过本次自动赎回,约 ${remaining} 秒后恢复")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
// 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
||||||
// 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理
|
// 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理
|
||||||
// 先执行赎回,赎回成功后再查找订单并更新订单状态
|
// 先执行赎回,赎回成功后再查找订单并更新订单状态
|
||||||
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
|
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
|
||||||
|
|
||||||
for ((accountId, positions) in positionsByAccount) {
|
for ((accountId, positions) in positionsByAccount) {
|
||||||
// 查找该账户下所有启用的跟单配置(仅用于赎回成功后更新跟单订单状态;无跟单配置的账户如尾盘策略账户也会执行赎回)
|
// 查找该账户下所有启用的跟单配置(仅用于赎回成功后更新跟单订单状态;无跟单配置的账户如加密价差策略账户也会执行赎回)
|
||||||
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
||||||
.filter { it.enabled }
|
.filter { it.enabled }
|
||||||
|
|
||||||
@@ -451,9 +467,11 @@ class PositionCheckService(
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理待赎回仓位异常: ${e.message}", e)
|
logger.error("处理待赎回仓位异常: ${e.message}", e)
|
||||||
|
} finally {
|
||||||
|
redeemCheckInProgress.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 逻辑2:处理未卖出订单
|
* 逻辑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 则解包为 USDC.e。
|
||||||
|
* 同一时间仅允许单次执行;若上次执行未结束则本次忽略(与现有轮询逻辑一致)。
|
||||||
|
* 若未配置 Builder API Key,直接跳过本轮(解包依赖 Relayer Gasless,未配置则无法执行)。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class WcolUnwrapJobService(
|
||||||
|
private val accountService: AccountService,
|
||||||
|
private val relayClientService: RelayClientService
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(WcolUnwrapJobService::class.java)
|
||||||
|
private val scope = kotlinx.coroutines.CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
private var unwrapJob: Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每 20 秒触发一次;若未配置 Builder Key 或当前任务仍在执行则跳过本次
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedRate = 20_000)
|
||||||
|
fun runWcolUnwrapPolling() {
|
||||||
|
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||||
|
logger.debug("Builder API Key 未配置,跳过 WCOL 解包轮询")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (unwrapJob?.isActive == true) {
|
||||||
|
logger.debug("上一轮 WCOL 解包任务仍在执行,跳过本次")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unwrapJob = scope.launch {
|
||||||
|
try {
|
||||||
|
accountService.runWcolUnwrapForAllAccounts()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("WCOL 解包轮询异常: ${e.message}", e)
|
||||||
|
} finally {
|
||||||
|
unwrapJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-12
@@ -9,7 +9,7 @@ import java.math.RoundingMode
|
|||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动最小价差:按周期计算。每个周期首次需要时,拉取该周期前的 20 根已收盘 K 线,按方向筛选、IQR 剔除后求平均,缓存 100% 基准值 (interval, period)。
|
* 自动最小价差:按周期计算。每个周期首次需要时,拉取该周期前的 20 根已收盘 K 线,按方向筛选、IQR 剔除后求平均,缓存 100% 基准值 (marketSlugPrefix, interval, period)。
|
||||||
* 触发时由调用方按窗口进度计算动态系数(100%→50%)后得到有效最小价差。不在保存策略时计算。
|
* 触发时由调用方按窗口进度计算动态系数(100%→50%)后得到有效最小价差。不在保存策略时计算。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -19,29 +19,68 @@ class BinanceKlineAutoSpreadService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java)
|
private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java)
|
||||||
|
|
||||||
private val symbol = "BTCUSDC"
|
/** 市场 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 historyLimit = 20
|
||||||
private val minSamplesAfterIqr = 3
|
private val minSamplesAfterIqr = 3
|
||||||
|
|
||||||
/** (intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown),100% 基准价差 */
|
/** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown),100% 基准价差 */
|
||||||
private val cache = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
private val cache = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||||
|
|
||||||
private fun cacheKey(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix"
|
/** 缓存保留时间(秒),超过则清理,防止无界增长 */
|
||||||
|
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% 基准价差,供调用方按窗口进度应用动态系数。 */
|
/** 返回该周期、该方向的 100% 基准价差,供调用方按窗口进度应用动态系数。 */
|
||||||
fun getAutoMinSpreadBase(intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
|
fun getAutoMinSpreadBase(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
|
||||||
val key = cacheKey(intervalSeconds, periodStartUnix)
|
val key = cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)
|
||||||
val (up, down) = cache[key] ?: run {
|
val (up, down) = cache[key] ?: run {
|
||||||
computeAndCache(intervalSeconds, periodStartUnix) ?: return null
|
computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix) ?: return null
|
||||||
}
|
}
|
||||||
return if (outcomeIndex == 0) up else down
|
return if (outcomeIndex == 0) up else down
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */
|
/** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */
|
||||||
fun computeAndCache(intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
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 intervalStr = if (intervalSeconds == 300) "5m" else "15m"
|
||||||
val endTimeMs = periodStartUnix * 1000L
|
val endTimeMs = periodStartUnix * 1000L
|
||||||
val klines = fetchKlines(intervalStr, historyLimit, endTime = endTimeMs) ?: return null
|
val klines = fetchKlines(symbol, intervalStr, historyLimit, endTime = endTimeMs) ?: return null
|
||||||
val spreadsUp = mutableListOf<BigDecimal>()
|
val spreadsUp = mutableListOf<BigDecimal>()
|
||||||
val spreadsDown = mutableListOf<BigDecimal>()
|
val spreadsDown = mutableListOf<BigDecimal>()
|
||||||
for (k in klines) {
|
for (k in klines) {
|
||||||
@@ -53,16 +92,16 @@ class BinanceKlineAutoSpreadService(
|
|||||||
}
|
}
|
||||||
val baseUp = averageAfterIqr(spreadsUp).setScale(8, RoundingMode.HALF_UP)
|
val baseUp = averageAfterIqr(spreadsUp).setScale(8, RoundingMode.HALF_UP)
|
||||||
val baseDown = averageAfterIqr(spreadsDown).setScale(8, RoundingMode.HALF_UP)
|
val baseDown = averageAfterIqr(spreadsDown).setScale(8, RoundingMode.HALF_UP)
|
||||||
cache[cacheKey(intervalSeconds, periodStartUnix)] = baseUp to baseDown
|
cache[cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)] = baseUp to baseDown
|
||||||
logger.info(
|
logger.info(
|
||||||
"尾盘自动价差已计算并缓存(100%基准): interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
|
"加密价差策略自动价差已计算并缓存(100%基准): market=$marketSlugPrefix symbol=$symbol interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
|
||||||
"Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " +
|
"Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " +
|
||||||
"Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}"
|
"Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}"
|
||||||
)
|
)
|
||||||
return baseUp to baseDown
|
return baseUp to baseDown
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchKlines(interval: String, limit: Int, endTime: Long? = null): List<List<Any>>? {
|
private fun fetchKlines(symbol: String, interval: String, limit: Int, endTime: Long? = null): List<List<Any>>? {
|
||||||
return try {
|
return try {
|
||||||
val api = retrofitFactory.createBinanceApi()
|
val api = retrofitFactory.createBinanceApi()
|
||||||
val call = api.getKlines(symbol = symbol, interval = interval, limit = limit, endTime = endTime)
|
val call = api.getKlines(symbol = symbol, interval = interval, limit = limit, endTime = endTime)
|
||||||
|
|||||||
+111
-66
@@ -16,10 +16,11 @@ import org.springframework.stereotype.Service
|
|||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import jakarta.annotation.PreDestroy
|
import jakarta.annotation.PreDestroy
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 币安 K 线 WebSocket:订阅 BTCUSDC 5m/15m,维护当前周期 (open, close),供尾盘策略价差校验使用。
|
* 币安 K 线 WebSocket:按需订阅加密价差策略使用的币种 5m/15m,维护当前周期 (open, close),供价差校验使用。
|
||||||
|
* 仅当存在启用策略且策略使用到某市场时才订阅对应币种,无策略时不建立连接。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class BinanceKlineService {
|
class BinanceKlineService {
|
||||||
@@ -28,91 +29,138 @@ class BinanceKlineService {
|
|||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
private val wsBase = "wss://stream.binance.com:9443"
|
private val wsBase = "wss://stream.binance.com:9443"
|
||||||
private val client = createClient().build()
|
private val client by lazy {
|
||||||
|
createClient().build()
|
||||||
|
}
|
||||||
|
|
||||||
/** (intervalSeconds, periodStartUnix) -> (open, close) */
|
/** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (open, close) */
|
||||||
private val openCloseByPeriod = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
private val openCloseByPeriod = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||||
private var ws5m: WebSocket? = null
|
|
||||||
private var ws15m: WebSocket? = null
|
/** 市场 slug 前缀(如 btc-updown)-> Binance 交易对映射 */
|
||||||
private var reconnectJob: Job? = null
|
private val marketToSymbol = mapOf(
|
||||||
private val connected5m = AtomicBoolean(false)
|
"btc-updown" to "BTCUSDC",
|
||||||
private val connected15m = AtomicBoolean(false)
|
"eth-updown" to "ETHUSDC",
|
||||||
|
"sol-updown" to "SOLUSDC",
|
||||||
init {
|
"xrp-updown" to "XRPUSDC"
|
||||||
connectAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun key(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix"
|
|
||||||
|
|
||||||
fun getCurrentOpenClose(intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
|
||||||
return openCloseByPeriod[key(intervalSeconds, periodStartUnix)]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 供 API 健康检查使用:5m / 15m 连接是否正常 */
|
|
||||||
fun getConnectionStatuses(): Map<String, Boolean> = mapOf(
|
|
||||||
"5m" to connected5m.get(),
|
|
||||||
"15m" to connected15m.get()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 已连接的 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
|
||||||
|
|
||||||
private fun connectAll() {
|
/** 解析完整市场 slug(如 btc-updown-5m)为 (basePrefix, interval),不支持则返回 null */
|
||||||
if (ws5m != null && ws15m != null) return
|
private fun parseMarketSlug(full: String): Pair<String, String>? {
|
||||||
connectStream("btcusdc@kline_5m") { intervalSec, tMs, openP, closeP ->
|
val lower = full.lowercase()
|
||||||
val periodSec = tMs / 1000
|
return when {
|
||||||
openCloseByPeriod[key(intervalSec, periodSec)] = openP to closeP
|
lower.endsWith("-5m") -> Pair(lower.removeSuffix("-5m"), "5m")
|
||||||
}.also { ws5m = it }
|
lower.endsWith("-15m") -> Pair(lower.removeSuffix("-15m"), "15m")
|
||||||
connectStream("btcusdc@kline_15m") { intervalSec, tMs, openP, closeP ->
|
else -> null
|
||||||
val periodSec = tMs / 1000
|
}
|
||||||
openCloseByPeriod[key(intervalSec, periodSec)] = openP to closeP
|
}
|
||||||
}.also { ws15m = it }
|
|
||||||
|
/** 从市场 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(
|
private fun connectStream(
|
||||||
streamName: String,
|
symbol: String,
|
||||||
onKline: (intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
|
interval: String,
|
||||||
): WebSocket {
|
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 url = "$wsBase/ws/$streamName"
|
||||||
val intervalSeconds = when {
|
val intervalSeconds = when (interval) {
|
||||||
streamName.contains("kline_5m") -> 300
|
"5m" -> 300
|
||||||
streamName.contains("kline_15m") -> 900
|
"15m" -> 900
|
||||||
else -> 300
|
else -> 300
|
||||||
}
|
}
|
||||||
val request = Request.Builder().url(url).build()
|
val request = Request.Builder().url(url).build()
|
||||||
val connectedFlag = when {
|
client.newWebSocket(request, object : WebSocketListener() {
|
||||||
streamName.contains("kline_5m") -> connected5m
|
|
||||||
streamName.contains("kline_15m") -> connected15m
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val ws = client.newWebSocket(request, object : WebSocketListener() {
|
|
||||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||||
connectedFlag?.set(true)
|
connectedWebSockets[wsKey] = webSocket
|
||||||
|
logger.info("币安 K 线 WS 已连接: $streamName")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
parseKlineMessage(text, intervalSeconds)?.let { (tMs, o, c) ->
|
parseKlineMessage(text)?.let { (tMs, o, c) ->
|
||||||
onKline(intervalSeconds, tMs, o, c)
|
onKline(marketPrefix, intervalSeconds, tMs, o, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||||
connectedFlag?.set(false)
|
connectedWebSockets.remove(wsKey)
|
||||||
logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}")
|
logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}")
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
connectedFlag?.set(false)
|
connectedWebSockets.remove(wsKey)
|
||||||
if (code != 1000) scheduleReconnect()
|
if (code != 1000) scheduleReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
connectedFlag?.set(false)
|
connectedWebSockets.remove(wsKey)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
logger.info("币安 K 线 WS 已连接: $streamName")
|
|
||||||
return ws
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseKlineMessage(text: String, intervalSeconds: Int): Triple<Long, BigDecimal, BigDecimal>? {
|
private fun parseKlineMessage(text: String): Triple<Long, BigDecimal, BigDecimal>? {
|
||||||
return try {
|
return try {
|
||||||
val json = com.google.gson.JsonParser.parseString(text).asJsonObject
|
val json = com.google.gson.JsonParser.parseString(text).asJsonObject
|
||||||
if (json.get("e")?.asString != "kline") return null
|
if (json.get("e")?.asString != "kline") return null
|
||||||
@@ -132,23 +180,20 @@ class BinanceKlineService {
|
|||||||
reconnectJob = scope.launch {
|
reconnectJob = scope.launch {
|
||||||
delay(3_000)
|
delay(3_000)
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
ws5m?.close(1000, "reconnect")
|
val current = requiredMarketPrefixes.get()
|
||||||
ws15m?.close(1000, "reconnect")
|
connectedWebSockets.values.forEach { it.close(1000, "reconnect") }
|
||||||
ws5m = null
|
connectedWebSockets.clear()
|
||||||
ws15m = null
|
|
||||||
connected5m.set(false)
|
|
||||||
connected15m.set(false)
|
|
||||||
logger.info("币安 K 线 WS 尝试重连")
|
logger.info("币安 K 线 WS 尝试重连")
|
||||||
connectAll()
|
// 清空 requiredMarketPrefixes,否则 updateSubscriptions(current) 内会因 normalized == requiredMarketPrefixes.get() 直接 return,不会重新 connectStream
|
||||||
|
requiredMarketPrefixes.set(emptySet())
|
||||||
|
updateSubscriptions(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
ws5m?.close(1000, "shutdown")
|
connectedWebSockets.values.forEach { it.close(1000, "shutdown") }
|
||||||
ws15m?.close(1000, "shutdown")
|
connectedWebSockets.clear()
|
||||||
ws5m = null
|
|
||||||
ws15m = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+241
-2
@@ -18,6 +18,7 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
|||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
@@ -54,6 +55,9 @@ class BlockchainService(
|
|||||||
|
|
||||||
// ConditionalTokens 合约地址(Polygon 主网)
|
// ConditionalTokens 合约地址(Polygon 主网)
|
||||||
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||||
|
|
||||||
|
// Neg Risk WrappedCollateral 合约地址(Polygon,解包后得 USDC.e)
|
||||||
|
private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
|
||||||
|
|
||||||
// 空集合ID(用于计算collectionId)
|
// 空集合ID(用于计算collectionId)
|
||||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
@@ -243,6 +247,62 @@ class BlockchainService(
|
|||||||
false
|
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 余额
|
* 查询账户 USDC 余额
|
||||||
@@ -587,6 +647,7 @@ class BlockchainService(
|
|||||||
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
|
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
|
||||||
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
||||||
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex)
|
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex)
|
||||||
|
* @param isNegRisk 是否为 Neg Risk 市场(true 时使用 WrappedCollateral 作为抵押品)
|
||||||
* @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径
|
* @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径
|
||||||
* @return 交易哈希
|
* @return 交易哈希
|
||||||
*/
|
*/
|
||||||
@@ -595,6 +656,7 @@ class BlockchainService(
|
|||||||
proxyAddress: String,
|
proxyAddress: String,
|
||||||
conditionId: String,
|
conditionId: String,
|
||||||
indexSets: List<BigInteger>,
|
indexSets: List<BigInteger>,
|
||||||
|
isNegRisk: Boolean = false,
|
||||||
walletType: WalletType = WalletType.SAFE
|
walletType: WalletType = WalletType.SAFE
|
||||||
): Result<String> {
|
): Result<String> {
|
||||||
return try {
|
return try {
|
||||||
@@ -608,14 +670,191 @@ class BlockchainService(
|
|||||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
|
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
|
||||||
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
|
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("赎回仓位失败: ${e.message}", e)
|
logger.error("赎回仓位失败: ${e.message}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量赎回多个市场的仓位(使用 MultiSend 合并为一笔交易)
|
||||||
|
* 仅支持 Safe 钱包类型,Magic 钱包不支持 MultiSend
|
||||||
|
*
|
||||||
|
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
|
||||||
|
* @param proxyAddress 代理地址(Safe 代理钱包地址)
|
||||||
|
* @param redeemRequests 赎回请求列表,每个元素是 (conditionId, indexSets, isNegRisk)
|
||||||
|
* @param walletType 钱包类型:仅支持 SAFE
|
||||||
|
* @return 交易哈希
|
||||||
|
*/
|
||||||
|
suspend fun redeemPositionsBatch(
|
||||||
|
privateKey: String,
|
||||||
|
proxyAddress: String,
|
||||||
|
redeemRequests: List<Triple<String, List<BigInteger>, Boolean>>,
|
||||||
|
walletType: WalletType = WalletType.SAFE
|
||||||
|
): Result<String> {
|
||||||
|
return try {
|
||||||
|
if (redeemRequests.isEmpty()) {
|
||||||
|
return Result.failure(IllegalArgumentException("redeemRequests 不能为空"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Magic 钱包不支持 MultiSend
|
||||||
|
if (walletType == WalletType.MAGIC) {
|
||||||
|
return Result.failure(IllegalArgumentException("Magic 钱包不支持 MultiSend 批量赎回,请使用逐笔赎回"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||||
|
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有 conditionId 格式
|
||||||
|
for ((conditionId, _, _) in redeemRequests) {
|
||||||
|
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
||||||
|
return Result.failure(IllegalArgumentException("conditionId 格式错误: $conditionId"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建每个市场的赎回交易(Neg Risk 市场使用 WrappedCollateral)
|
||||||
|
val redeemTxs = redeemRequests.map { (conditionId, indexSets, isNegRisk) ->
|
||||||
|
if (indexSets.isEmpty()) {
|
||||||
|
throw IllegalArgumentException("indexSets 不能为空: $conditionId")
|
||||||
|
}
|
||||||
|
relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 MultiSend 合并所有交易
|
||||||
|
val multiSendTx = relayClientService.createMultiSendTx(redeemTxs)
|
||||||
|
|
||||||
|
logger.info("批量赎回: 合并 ${redeemRequests.size} 个市场为一笔交易")
|
||||||
|
|
||||||
|
relayClientService.execute(privateKey, proxyAddress, multiSendTx, walletType)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("批量赎回仓位失败: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询等待交易上链并确认成功
|
||||||
|
* @param txHash 交易 hash(0x 开头)
|
||||||
|
* @param maxWaitMs 最大等待毫秒数
|
||||||
|
* @param pollIntervalMs 轮询间隔毫秒数
|
||||||
|
* @return 成功返回 Unit,超时或 revert 返回 Result.failure
|
||||||
|
*/
|
||||||
|
suspend fun waitForTransactionConfirmed(
|
||||||
|
txHash: String,
|
||||||
|
maxWaitMs: Long = 120_000,
|
||||||
|
pollIntervalMs: Long = 3_000
|
||||||
|
): Result<Unit> {
|
||||||
|
val rpcApi = polygonRpcApi
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
while (System.currentTimeMillis() - start < maxWaitMs) {
|
||||||
|
val req = JsonRpcRequest(method = "eth_getTransactionReceipt", params = listOf(txHash))
|
||||||
|
val response = rpcApi.call(req)
|
||||||
|
if (!response.isSuccessful || response.body() == null) {
|
||||||
|
delay(pollIntervalMs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val body = response.body()!!
|
||||||
|
if (body.error != null) {
|
||||||
|
delay(pollIntervalMs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val result = body.result
|
||||||
|
if (result == null || result.isJsonNull) {
|
||||||
|
delay(pollIntervalMs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val status = result.asJsonObject?.get("status")?.asString
|
||||||
|
if (status == null) {
|
||||||
|
delay(pollIntervalMs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return when (status) {
|
||||||
|
"0x1" -> Result.success(Unit)
|
||||||
|
"0x0" -> Result.failure(Exception("交易已上链但执行失败 (revert)"))
|
||||||
|
else -> Result.failure(Exception("交易状态异常: $status"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result.failure(Exception("等待交易确认超时 (${maxWaitMs}ms)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询代理地址的 WCOL(Wrapped Collateral)余额(raw,6 位小数)
|
||||||
|
*/
|
||||||
|
suspend fun getWcolBalance(proxyAddress: String): Result<BigInteger> {
|
||||||
|
val rpcApi = polygonRpcApi
|
||||||
|
val functionSelector = "0x70a08231" // balanceOf(address)
|
||||||
|
val paddedAddress = proxyAddress.removePrefix("0x").lowercase().padStart(64, '0')
|
||||||
|
val data = functionSelector + paddedAddress
|
||||||
|
val rpcRequest = JsonRpcRequest(
|
||||||
|
method = "eth_call",
|
||||||
|
params = listOf(
|
||||||
|
mapOf(
|
||||||
|
"to" to wcolContractAddress,
|
||||||
|
"data" to data
|
||||||
|
),
|
||||||
|
"latest"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val response = rpcApi.call(rpcRequest)
|
||||||
|
if (!response.isSuccessful || response.body() == null) {
|
||||||
|
return Result.failure(Exception("查询 WCOL 余额失败: ${response.code()} ${response.message()}"))
|
||||||
|
}
|
||||||
|
val rpcResponse = response.body()!!
|
||||||
|
if (rpcResponse.error != null) {
|
||||||
|
return Result.failure(Exception("查询 WCOL 余额失败: ${rpcResponse.error.message}"))
|
||||||
|
}
|
||||||
|
val hexBalance = rpcResponse.result?.asString ?: return Result.failure(Exception("WCOL 余额结果为空"))
|
||||||
|
val balance = EthereumUtils.decodeUint256(hexBalance)
|
||||||
|
return Result.success(balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将代理钱包内的 WCOL 解包为 USDC.e(解包后转入代理地址)
|
||||||
|
* 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可转为 USDC.e 以便显示/使用。
|
||||||
|
*
|
||||||
|
* Safe 与 Magic 使用同一套逻辑:同一 [createUnwrapWcolTx] + [RelayClientService.execute];
|
||||||
|
* Safe 走 execTransaction,Magic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount),USDC.e 转入 proxyAddress。
|
||||||
|
*
|
||||||
|
* @param privateKey 主钱包私钥
|
||||||
|
* @param proxyAddress 代理地址(Safe 或 Magic 代理)
|
||||||
|
* @param walletType 钱包类型(SAFE / MAGIC),用于选择 Relayer 执行路径
|
||||||
|
* @return 成功返回交易 hash,余额为 0 返回 null,失败返回 Result.failure
|
||||||
|
*/
|
||||||
|
suspend fun unwrapWcolForProxy(
|
||||||
|
privateKey: String,
|
||||||
|
proxyAddress: String,
|
||||||
|
walletType: WalletType
|
||||||
|
): Result<String?> {
|
||||||
|
return try {
|
||||||
|
val balanceResult = getWcolBalance(proxyAddress)
|
||||||
|
val balance = balanceResult.getOrElse {
|
||||||
|
logger.warn("查询 WCOL 余额失败,跳过解包: ${it.message}")
|
||||||
|
return Result.success(null)
|
||||||
|
}
|
||||||
|
if (balance == BigInteger.ZERO) {
|
||||||
|
return Result.success(null)
|
||||||
|
}
|
||||||
|
val unwrapTx = relayClientService.createUnwrapWcolTx(proxyAddress, balance)
|
||||||
|
val executeResult = relayClientService.execute(privateKey, proxyAddress, unwrapTx, walletType)
|
||||||
|
executeResult.fold(
|
||||||
|
onSuccess = { txHash ->
|
||||||
|
logger.info("WCOL 解包成功: proxy=${proxyAddress.take(10)}..., txHash=$txHash")
|
||||||
|
Result.success(txHash)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("WCOL 解包失败: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("WCOL 解包异常: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取代理钱包的 nonce(用于构建 Safe 交易)
|
* 获取代理钱包的 nonce(用于构建 Safe 交易)
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1
-1
@@ -401,7 +401,7 @@ class PolymarketClobService(
|
|||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取费率
|
* 获取费率
|
||||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
* 文档: 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
|
package com.wrbug.polymarketbot.service.common
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData
|
||||||
import com.wrbug.polymarketbot.dto.OrderPushMessage
|
import com.wrbug.polymarketbot.dto.OrderPushMessage
|
||||||
import com.wrbug.polymarketbot.dto.PositionPushMessage
|
import com.wrbug.polymarketbot.dto.PositionPushMessage
|
||||||
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
|
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
|
||||||
import com.wrbug.polymarketbot.dto.WebSocketMessageType
|
import com.wrbug.polymarketbot.dto.WebSocketMessageType
|
||||||
import com.wrbug.polymarketbot.service.accounts.PositionPushService
|
import com.wrbug.polymarketbot.service.accounts.PositionPushService
|
||||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||||
|
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -38,28 +40,47 @@ class WebSocketSubscriptionService(
|
|||||||
// 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅)
|
// 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅)
|
||||||
private val orderChannelCallbacks = ConcurrentHashMap<String, (OrderPushMessage) -> Unit>()
|
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) {
|
fun registerSession(sessionId: String, callback: (WsMessage) -> Unit) {
|
||||||
sessionCallbacks[sessionId] = callback
|
sessionCallbacks[sessionId] = callback
|
||||||
sessionSubscriptions[sessionId] = mutableSetOf()
|
sessionSubscriptions[sessionId] = mutableSetOf()
|
||||||
|
monitorChannelCallbacks[sessionId] = mutableMapOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注销会话
|
* 注销会话
|
||||||
*/
|
*/
|
||||||
fun unregisterSession(sessionId: String) {
|
fun unregisterSession(sessionId: String) {
|
||||||
|
|
||||||
// 取消所有订阅
|
// 取消所有订阅
|
||||||
val channels = sessionSubscriptions.remove(sessionId) ?: emptySet()
|
val channels = sessionSubscriptions.remove(sessionId) ?: emptySet()
|
||||||
channels.forEach { channel ->
|
channels.forEach { channel ->
|
||||||
unsubscribe(sessionId, channel)
|
unsubscribe(sessionId, channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理 order 频道的回调
|
// 清理 order 频道的回调
|
||||||
orderChannelCallbacks.remove(sessionId)
|
orderChannelCallbacks.remove(sessionId)
|
||||||
|
|
||||||
|
// 清理加密价差策略监控频道的回调
|
||||||
|
val monitorCallbacks = monitorChannelCallbacks.remove(sessionId)
|
||||||
|
monitorCallbacks?.keys?.forEach { strategyId ->
|
||||||
|
cryptoTailMonitorService?.unsubscribe(sessionId, strategyId)
|
||||||
|
}
|
||||||
|
|
||||||
sessionCallbacks.remove(sessionId)
|
sessionCallbacks.remove(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +104,8 @@ class WebSocketSubscriptionService(
|
|||||||
sendSubscribeAck(sessionId, channel, true)
|
sendSubscribeAck(sessionId, channel, true)
|
||||||
|
|
||||||
// 根据频道类型启动推送服务
|
// 根据频道类型启动推送服务
|
||||||
when (channel) {
|
when {
|
||||||
"position" -> {
|
channel == "position" -> {
|
||||||
positionPushService.subscribe(sessionId) { message ->
|
positionPushService.subscribe(sessionId) { message ->
|
||||||
pushData(sessionId, channel, message)
|
pushData(sessionId, channel, message)
|
||||||
}
|
}
|
||||||
@@ -97,7 +118,7 @@ class WebSocketSubscriptionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"order" -> {
|
channel == "order" -> {
|
||||||
// 订单推送:自动订阅所有启用的账户
|
// 订单推送:自动订阅所有启用的账户
|
||||||
val callback: (OrderPushMessage) -> Unit = { message ->
|
val callback: (OrderPushMessage) -> Unit = { message ->
|
||||||
pushData(sessionId, channel, message)
|
pushData(sessionId, channel, message)
|
||||||
@@ -105,6 +126,20 @@ class WebSocketSubscriptionService(
|
|||||||
orderChannelCallbacks[sessionId] = callback
|
orderChannelCallbacks[sessionId] = callback
|
||||||
orderPushService.subscribeAllEnabled(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 -> {
|
else -> {
|
||||||
logger.warn("未知的频道: $channel")
|
logger.warn("未知的频道: $channel")
|
||||||
sendSubscribeAck(sessionId, channel, false, "未知的频道")
|
sendSubscribeAck(sessionId, channel, false, "未知的频道")
|
||||||
@@ -122,15 +157,58 @@ class WebSocketSubscriptionService(
|
|||||||
channelSubscriptions[channel]?.remove(sessionId)
|
channelSubscriptions[channel]?.remove(sessionId)
|
||||||
|
|
||||||
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
|
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
|
||||||
when (channel) {
|
when {
|
||||||
"position" -> positionPushService.unsubscribe(sessionId)
|
channel == "position" -> positionPushService.unsubscribe(sessionId)
|
||||||
"order" -> {
|
channel == "order" -> {
|
||||||
// 取消订阅所有账户的订单推送
|
// 取消订阅所有账户的订单推送
|
||||||
val callback = orderChannelCallbacks.remove(sessionId)
|
val callback = orderChannelCallbacks.remove(sessionId)
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
orderPushService.unsubscribeAll(callback)
|
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(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-4
@@ -20,7 +20,12 @@ import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
|||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.service.common.MarketService
|
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 org.springframework.stereotype.Service
|
||||||
|
import java.math.BigDecimal
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -426,20 +431,29 @@ class OrderPushService(
|
|||||||
// 获取市场信息(使用 MarketService,优先从数据库/缓存获取)
|
// 获取市场信息(使用 MarketService,优先从数据库/缓存获取)
|
||||||
val market = marketService.getMarket(conditionId ?: openOrder.market)
|
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),需要转换为字符串
|
// 注意:createdAt 是 unix timestamp (Long),需要转换为字符串
|
||||||
OrderDetailDto(
|
OrderDetailDto(
|
||||||
id = openOrder.id,
|
id = openOrder.id,
|
||||||
market = openOrder.market,
|
market = openOrder.market,
|
||||||
side = openOrder.side,
|
side = openOrder.side,
|
||||||
price = openOrder.price,
|
price = openOrder.price,
|
||||||
size = openOrder.originalSize, // 使用 original_size
|
size = openOrder.originalSize,
|
||||||
filled = openOrder.sizeMatched, // 使用 size_matched
|
filled = openOrder.sizeMatched, // 已成交数量用 size_matched
|
||||||
status = openOrder.status,
|
status = openOrder.status,
|
||||||
createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串
|
createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串
|
||||||
marketName = market?.title,
|
marketName = market?.title,
|
||||||
marketSlug = market?.slug, // 显示用的 slug
|
marketSlug = market?.slug, // 显示用的 slug
|
||||||
marketIcon = market?.icon
|
marketIcon = market?.icon,
|
||||||
|
avgFilledPrice = avgFilledPrice?.toPlainString() // 实际成交价 = original_size*price/size_matched
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
|
|||||||
+56
-11
@@ -7,8 +7,10 @@ import com.wrbug.polymarketbot.service.common.MarketService
|
|||||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
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.multi
|
||||||
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||||
@@ -38,7 +40,8 @@ class OrderStatusUpdateService(
|
|||||||
private val cryptoUtils: CryptoUtils,
|
private val cryptoUtils: CryptoUtils,
|
||||||
private val trackingService: CopyOrderTrackingService,
|
private val trackingService: CopyOrderTrackingService,
|
||||||
private val marketService: MarketService, // 市场信息服务
|
private val marketService: MarketService, // 市场信息服务
|
||||||
private val telegramNotificationService: TelegramNotificationService?
|
private val telegramNotificationService: TelegramNotificationService?,
|
||||||
|
private val blockchainService: com.wrbug.polymarketbot.service.common.BlockchainService
|
||||||
) : ApplicationContextAware {
|
) : ApplicationContextAware {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java)
|
private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java)
|
||||||
@@ -555,11 +558,13 @@ class OrderStatusUpdateService(
|
|||||||
|
|
||||||
logger.info("更新卖出订单价格成功: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
|
logger.info("更新卖出订单价格成功: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
|
||||||
|
|
||||||
// 发送通知(使用实际价格)
|
// 发送通知(使用实际成交价)
|
||||||
sendSellOrderNotification(
|
sendSellOrderNotification(
|
||||||
record = updatedRecord,
|
record = updatedRecord,
|
||||||
actualPrice = actualSellPrice.toString(),
|
actualPrice = actualSellPrice.toString(),
|
||||||
actualSize = record.totalMatchedQuantity.toString(),
|
actualSize = record.totalMatchedQuantity.toString(),
|
||||||
|
avgFilledPrice = actualSellPrice.toString(),
|
||||||
|
filled = record.totalMatchedQuantity.toString(),
|
||||||
account = account,
|
account = account,
|
||||||
copyTrading = copyTrading,
|
copyTrading = copyTrading,
|
||||||
clobApi = clobApi,
|
clobApi = clobApi,
|
||||||
@@ -588,11 +593,13 @@ class OrderStatusUpdateService(
|
|||||||
|
|
||||||
logger.debug("卖出订单价格无需更新: orderId=${record.sellOrderId}, price=$actualSellPrice")
|
logger.debug("卖出订单价格无需更新: orderId=${record.sellOrderId}, price=$actualSellPrice")
|
||||||
|
|
||||||
// 发送通知
|
// 发送通知(使用实际成交价)
|
||||||
sendSellOrderNotification(
|
sendSellOrderNotification(
|
||||||
record = updatedRecord,
|
record = updatedRecord,
|
||||||
actualPrice = actualSellPrice.toString(),
|
actualPrice = actualSellPrice.toString(),
|
||||||
actualSize = record.totalMatchedQuantity.toString(),
|
actualSize = record.totalMatchedQuantity.toString(),
|
||||||
|
avgFilledPrice = actualSellPrice.toString(),
|
||||||
|
filled = record.totalMatchedQuantity.toString(),
|
||||||
account = account,
|
account = account,
|
||||||
copyTrading = copyTrading,
|
copyTrading = copyTrading,
|
||||||
clobApi = clobApi,
|
clobApi = clobApi,
|
||||||
@@ -844,12 +851,24 @@ class OrderStatusUpdateService(
|
|||||||
logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}")
|
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(
|
sendBuyOrderNotification(
|
||||||
order = updatedOrder,
|
order = updatedOrder,
|
||||||
actualPrice = actualPrice.toString(),
|
actualPrice = actualPrice.toString(),
|
||||||
actualSize = actualSize.toString(),
|
actualSize = actualSize.toString(),
|
||||||
actualOutcome = actualOutcome,
|
actualOutcome = actualOutcome,
|
||||||
|
avgFilledPrice = avgFilledPriceStr,
|
||||||
|
filled = filledSize,
|
||||||
account = account,
|
account = account,
|
||||||
copyTrading = copyTrading,
|
copyTrading = copyTrading,
|
||||||
clobApi = clobApi,
|
clobApi = clobApi,
|
||||||
@@ -876,6 +895,8 @@ class OrderStatusUpdateService(
|
|||||||
actualPrice: String? = null,
|
actualPrice: String? = null,
|
||||||
actualSize: String? = null,
|
actualSize: String? = null,
|
||||||
actualOutcome: String? = null,
|
actualOutcome: String? = null,
|
||||||
|
avgFilledPrice: String? = null, // 平均成交价(有成交时用于 TG 展示)
|
||||||
|
filled: String? = null, // 已成交数量(与 avgFilledPrice 一起用于金额计算)
|
||||||
account: Account? = null,
|
account: Account? = null,
|
||||||
copyTrading: CopyTrading? = null,
|
copyTrading: CopyTrading? = null,
|
||||||
clobApi: PolymarketClobApi? = null,
|
clobApi: PolymarketClobApi? = null,
|
||||||
@@ -930,14 +951,24 @@ class OrderStatusUpdateService(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送通知
|
// 查询可用余额
|
||||||
|
val availableBalance = try {
|
||||||
|
blockchainService.getUsdcBalance(finalAccount.walletAddress, finalAccount.proxyAddress).getOrNull()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("查询可用余额失败: accountId=${finalAccount.id}, ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送通知(优先使用平均成交价展示)
|
||||||
telegramNotificationService.sendOrderSuccessNotification(
|
telegramNotificationService.sendOrderSuccessNotification(
|
||||||
orderId = order.buyOrderId,
|
orderId = order.buyOrderId,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketId = order.marketId,
|
marketId = order.marketId,
|
||||||
marketSlug = market?.eventSlug, // 跳转用的 slug
|
marketSlug = market?.eventSlug, // 跳转用的 slug
|
||||||
side = "BUY",
|
side = "BUY",
|
||||||
price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格
|
price = actualPrice ?: order.price.toString(), // 限价,无 avgFilledPrice 时展示
|
||||||
|
avgFilledPrice = avgFilledPrice,
|
||||||
|
filled = filled,
|
||||||
size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量
|
size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量
|
||||||
outcome = actualOutcome, // 使用实际 outcome
|
outcome = actualOutcome, // 使用实际 outcome
|
||||||
accountName = finalAccount.accountName,
|
accountName = finalAccount.accountName,
|
||||||
@@ -950,7 +981,8 @@ class OrderStatusUpdateService(
|
|||||||
locale = locale,
|
locale = locale,
|
||||||
leaderName = leaderName,
|
leaderName = leaderName,
|
||||||
configName = configName,
|
configName = configName,
|
||||||
orderTime = orderCreatedAt // 使用订单创建时间
|
orderTime = orderCreatedAt, // 使用订单创建时间
|
||||||
|
availableBalance = availableBalance
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}")
|
logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}")
|
||||||
@@ -969,6 +1001,8 @@ class OrderStatusUpdateService(
|
|||||||
actualPrice: String? = null,
|
actualPrice: String? = null,
|
||||||
actualSize: String? = null,
|
actualSize: String? = null,
|
||||||
actualOutcome: String? = null,
|
actualOutcome: String? = null,
|
||||||
|
avgFilledPrice: String? = null, // 平均成交价(有成交时用于 TG 展示)
|
||||||
|
filled: String? = null, // 已成交数量(与 avgFilledPrice 一起用于金额计算)
|
||||||
account: Account? = null,
|
account: Account? = null,
|
||||||
copyTrading: CopyTrading? = null,
|
copyTrading: CopyTrading? = null,
|
||||||
clobApi: PolymarketClobApi? = null,
|
clobApi: PolymarketClobApi? = null,
|
||||||
@@ -1023,14 +1057,24 @@ class OrderStatusUpdateService(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送通知
|
// 查询可用余额
|
||||||
|
val availableBalance = try {
|
||||||
|
blockchainService.getUsdcBalance(finalAccount.walletAddress, finalAccount.proxyAddress).getOrNull()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("查询可用余额失败: accountId=${finalAccount.id}, ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送通知(优先使用平均成交价展示)
|
||||||
telegramNotificationService.sendOrderSuccessNotification(
|
telegramNotificationService.sendOrderSuccessNotification(
|
||||||
orderId = record.sellOrderId,
|
orderId = record.sellOrderId,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketId = record.marketId,
|
marketId = record.marketId,
|
||||||
marketSlug = market?.eventSlug, // 跳转用的 slug
|
marketSlug = market?.eventSlug, // 跳转用的 slug
|
||||||
side = "SELL",
|
side = "SELL",
|
||||||
price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格
|
price = actualPrice ?: record.sellPrice.toString(), // 限价,无 avgFilledPrice 时展示
|
||||||
|
avgFilledPrice = avgFilledPrice,
|
||||||
|
filled = filled,
|
||||||
size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量
|
size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量
|
||||||
outcome = actualOutcome, // 使用实际 outcome
|
outcome = actualOutcome, // 使用实际 outcome
|
||||||
accountName = finalAccount.accountName,
|
accountName = finalAccount.accountName,
|
||||||
@@ -1043,7 +1087,8 @@ class OrderStatusUpdateService(
|
|||||||
locale = locale,
|
locale = locale,
|
||||||
leaderName = leaderName,
|
leaderName = leaderName,
|
||||||
configName = configName,
|
configName = configName,
|
||||||
orderTime = orderCreatedAt // 使用订单创建时间
|
orderTime = orderCreatedAt, // 使用订单创建时间
|
||||||
|
availableBalance = availableBalance
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}")
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-9
@@ -6,6 +6,10 @@ import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
|||||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||||
import com.wrbug.polymarketbot.service.common.MarketService
|
import com.wrbug.polymarketbot.service.common.MarketService
|
||||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
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.CryptoUtils
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -19,9 +23,11 @@ import org.springframework.context.ApplicationContextAware
|
|||||||
import org.springframework.scheduling.annotation.Scheduled
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略订单 TG 通知轮询服务(与跟单一致)
|
* 加密价差策略订单 TG 通知轮询服务(与跟单一致)
|
||||||
* 定时查询「下单成功且未发 TG」的触发记录,通过 CLOB getOrder 获取订单详情后发送 TG 并标记已发。
|
* 定时查询「下单成功且未发 TG」的触发记录,通过 CLOB getOrder 获取订单详情后发送 TG 并标记已发。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -36,7 +42,8 @@ class CryptoTailOrderNotificationPollingService(
|
|||||||
) : ApplicationContextAware {
|
) : ApplicationContextAware {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderNotificationPollingService::class.java)
|
private val logger = LoggerFactory.getLogger(CryptoTailOrderNotificationPollingService::class.java)
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scopeJob = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + scopeJob)
|
||||||
|
|
||||||
private var applicationContext: ApplicationContext? = null
|
private var applicationContext: ApplicationContext? = null
|
||||||
|
|
||||||
@@ -55,14 +62,14 @@ class CryptoTailOrderNotificationPollingService(
|
|||||||
@Scheduled(fixedDelay = 5000)
|
@Scheduled(fixedDelay = 5000)
|
||||||
fun scheduledSendPendingNotifications() {
|
fun scheduledSendPendingNotifications() {
|
||||||
if (notificationJob != null && notificationJob!!.isActive) {
|
if (notificationJob != null && notificationJob!!.isActive) {
|
||||||
logger.debug("上一轮尾盘 TG 通知任务仍在执行,跳过本次")
|
logger.debug("上一轮加密价差策略 TG 通知任务仍在执行,跳过本次")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
notificationJob = scope.launch {
|
notificationJob = scope.launch {
|
||||||
try {
|
try {
|
||||||
getSelf().sendPendingNotifications()
|
getSelf().sendPendingNotifications()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("尾盘 TG 通知轮询异常: ${e.message}", e)
|
logger.error("加密价差策略 TG 通知轮询异常: ${e.message}", e)
|
||||||
} finally {
|
} finally {
|
||||||
notificationJob = null
|
notificationJob = null
|
||||||
}
|
}
|
||||||
@@ -86,7 +93,7 @@ class CryptoTailOrderNotificationPollingService(
|
|||||||
triggerRepository.save(trigger)
|
triggerRepository.save(trigger)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("尾盘 TG 通知单条失败: triggerId=${trigger.id}, orderId=${trigger.orderId}, ${e.message}", e)
|
logger.warn("加密价差策略 TG 通知单条失败: triggerId=${trigger.id}, orderId=${trigger.orderId}, ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,16 +107,16 @@ class CryptoTailOrderNotificationPollingService(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val apiSecret = try {
|
val apiSecret = try {
|
||||||
cryptoUtils.decrypt(account.apiSecret) ?: return false
|
cryptoUtils.decrypt(account.apiSecret)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("解密 API Secret 失败: accountId=${account.id}", e)
|
logger.warn("解密 API Secret 失败: accountId=${account.id}", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val apiPassphrase = try {
|
val apiPassphrase = try {
|
||||||
cryptoUtils.decrypt(account.apiPassphrase) ?: ""
|
cryptoUtils.decrypt(account.apiPassphrase)
|
||||||
} catch (e: Exception) { "" }
|
} catch (e: Exception) { "" }
|
||||||
val clobApi = retrofitFactory.createClobApi(
|
val clobApi = retrofitFactory.createClobApi(
|
||||||
account.apiKey!!,
|
account.apiKey,
|
||||||
apiSecret,
|
apiSecret,
|
||||||
apiPassphrase,
|
apiPassphrase,
|
||||||
account.walletAddress
|
account.walletAddress
|
||||||
@@ -126,6 +133,15 @@ class CryptoTailOrderNotificationPollingService(
|
|||||||
val market = marketService.getMarket(order.market)
|
val market = marketService.getMarket(order.market)
|
||||||
val marketTitle = trigger.marketTitle?.takeIf { it.isNotBlank() } ?: market?.title ?: 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
|
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(
|
telegramNotificationService.sendCryptoTailOrderSuccessNotification(
|
||||||
orderId = orderId,
|
orderId = orderId,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
@@ -135,12 +151,21 @@ class CryptoTailOrderNotificationPollingService(
|
|||||||
outcome = order.outcome,
|
outcome = order.outcome,
|
||||||
price = order.price,
|
price = order.price,
|
||||||
size = order.originalSize,
|
size = order.originalSize,
|
||||||
|
avgFilledPrice = avgFilledPriceStr,
|
||||||
|
filled = filledSize,
|
||||||
strategyName = strategy.name,
|
strategyName = strategy.name,
|
||||||
accountName = account.accountName,
|
accountName = account.accountName,
|
||||||
walletAddress = account.walletAddress,
|
walletAddress = account.walletAddress,
|
||||||
orderTime = orderTimeMs
|
orderTime = orderTimeMs
|
||||||
)
|
)
|
||||||
logger.info("尾盘订单 TG 通知已发送: orderId=$orderId, strategyId=${strategy.id}, triggerId=${trigger.id}")
|
logger.info("加密价差策略订单 TG 通知已发送: orderId=$orderId, strategyId=${strategy.id}, triggerId=${trigger.id}")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun destroy() {
|
||||||
|
notificationJob?.cancel()
|
||||||
|
notificationJob = null
|
||||||
|
scopeJob.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-78
@@ -3,12 +3,15 @@ package com.wrbug.polymarketbot.service.cryptotail
|
|||||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||||
|
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
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.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import com.wrbug.polymarketbot.util.fromJson
|
import com.wrbug.polymarketbot.util.fromJson
|
||||||
|
import com.wrbug.polymarketbot.util.gt
|
||||||
import com.wrbug.polymarketbot.util.toJson
|
import com.wrbug.polymarketbot.util.toJson
|
||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -26,24 +29,27 @@ import org.slf4j.LoggerFactory
|
|||||||
import org.springframework.context.event.EventListener
|
import org.springframework.context.event.EventListener
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import jakarta.annotation.PostConstruct
|
import jakarta.annotation.PostConstruct
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略订单簿 WebSocket 监听:订阅 CLOB Market 频道,收到订单簿/价格变更时若满足条件立即触发下单。
|
* 加密价差策略订单簿 WebSocket 监听:订阅 CLOB Market 频道,收到订单簿/价格变更时若满足条件立即触发下单。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class CryptoTailOrderbookWsService(
|
class CryptoTailOrderbookWsService(
|
||||||
private val strategyRepository: CryptoTailStrategyRepository,
|
private val strategyRepository: CryptoTailStrategyRepository,
|
||||||
private val executionService: CryptoTailStrategyExecutionService,
|
private val executionService: CryptoTailStrategyExecutionService,
|
||||||
private val retrofitFactory: RetrofitFactory,
|
private val retrofitFactory: RetrofitFactory,
|
||||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService
|
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||||
|
private val binanceKlineService: BinanceKlineService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderbookWsService::class.java)
|
private val logger = LoggerFactory.getLogger(CryptoTailOrderbookWsService::class.java)
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scopeJob = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Default + scopeJob)
|
||||||
|
|
||||||
/** tokenId -> list of (strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex) */
|
/** tokenId -> list of (strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex) */
|
||||||
private val tokenToEntries = AtomicReference<Map<String, List<WsBookEntry>>>(emptyMap())
|
private val tokenToEntries = AtomicReference<Map<String, List<WsBookEntry>>>(emptyMap())
|
||||||
@@ -64,6 +70,12 @@ class CryptoTailOrderbookWsService(
|
|||||||
/** 保护 connect() 的互斥锁,避免多线程并发创建连接 */
|
/** 保护 connect() 的互斥锁,避免多线程并发创建连接 */
|
||||||
private val connectLock = Any()
|
private val connectLock = Any()
|
||||||
|
|
||||||
|
/** 保护 refreshAndSubscribe() 的互斥锁,避免多线程并发刷新订阅 */
|
||||||
|
private val refreshLock = Any()
|
||||||
|
|
||||||
|
/** 标记是否正在刷新订阅,避免重复调用 */
|
||||||
|
private val isRefreshing = AtomicBoolean(false)
|
||||||
|
|
||||||
data class WsBookEntry(
|
data class WsBookEntry(
|
||||||
val strategy: CryptoTailStrategy,
|
val strategy: CryptoTailStrategy,
|
||||||
val periodStartUnix: Long,
|
val periodStartUnix: Long,
|
||||||
@@ -77,6 +89,26 @@ class CryptoTailOrderbookWsService(
|
|||||||
if (strategyRepository.findAllByEnabledTrue().isNotEmpty()) connect()
|
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() {
|
private fun connect() {
|
||||||
synchronized(connectLock) {
|
synchronized(connectLock) {
|
||||||
if (webSocket != null) return
|
if (webSocket != null) return
|
||||||
@@ -84,7 +116,7 @@ class CryptoTailOrderbookWsService(
|
|||||||
val request = Request.Builder().url(wsUrl).build()
|
val request = Request.Builder().url(wsUrl).build()
|
||||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||||
logger.info("尾盘策略订单簿 WebSocket 已连接")
|
logger.info("加密价差策略订单簿 WebSocket 已连接")
|
||||||
refreshAndSubscribe(fromConnect = true)
|
refreshAndSubscribe(fromConnect = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,13 +130,13 @@ class CryptoTailOrderbookWsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||||
logger.warn("尾盘策略订单簿 WebSocket 异常: ${t.message}")
|
logger.warn("加密价差策略订单簿 WebSocket 异常: ${t.message}")
|
||||||
this@CryptoTailOrderbookWsService.webSocket = null
|
this@CryptoTailOrderbookWsService.webSocket = null
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("尾盘策略订单簿 WebSocket 连接失败: ${e.message}", e)
|
logger.error("加密价差策略订单簿 WebSocket 连接失败: ${e.message}", e)
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +150,7 @@ class CryptoTailOrderbookWsService(
|
|||||||
delay(reconnectDelayMs)
|
delay(reconnectDelayMs)
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
if (strategyRepository.findAllByEnabledTrue().isEmpty()) return@launch
|
if (strategyRepository.findAllByEnabledTrue().isEmpty()) return@launch
|
||||||
logger.info("尾盘策略订单簿 WebSocket 尝试重连")
|
logger.info("加密价差策略订单簿 WebSocket 尝试重连")
|
||||||
connect()
|
connect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,8 +167,13 @@ class CryptoTailOrderbookWsService(
|
|||||||
val assetId = (json.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
val assetId = (json.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
||||||
val bids = json.get("bids") as? com.google.gson.JsonArray
|
val bids = json.get("bids") as? com.google.gson.JsonArray
|
||||||
if (bids == null || bids.isEmpty) return
|
if (bids == null || bids.isEmpty) return
|
||||||
val firstBid = bids.get(0) as? com.google.gson.JsonObject
|
// Polymarket book 的 bids 为价格升序,bids[0] 为最低买价;bestBid 应取最高买价
|
||||||
val bestBid = (firstBid?.get("price") as? com.google.gson.JsonPrimitive)?.asString?.toSafeBigDecimal()
|
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)
|
if (bestBid != null) onBestBid(assetId, bestBid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +193,7 @@ class CryptoTailOrderbookWsService(
|
|||||||
private fun onBestBid(tokenId: String, bestBid: BigDecimal) {
|
private fun onBestBid(tokenId: String, bestBid: BigDecimal) {
|
||||||
if (closedForNoStrategies.get()) return
|
if (closedForNoStrategies.get()) return
|
||||||
val entries = tokenToEntries.get()[tokenId]
|
val entries = tokenToEntries.get()[tokenId]
|
||||||
if (entries == null) {
|
if (entries == null) return
|
||||||
logger.debug("tokenToEntries null: $tokenId")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val nowSeconds = System.currentTimeMillis() / 1000
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
for (e in entries) {
|
for (e in entries) {
|
||||||
val windowStart = e.periodStartUnix + e.strategy.windowStartSeconds
|
val windowStart = e.periodStartUnix + e.strategy.windowStartSeconds
|
||||||
@@ -167,16 +201,14 @@ class CryptoTailOrderbookWsService(
|
|||||||
if (nowSeconds < windowStart || nowSeconds >= windowEnd) continue
|
if (nowSeconds < windowStart || nowSeconds >= windowEnd) continue
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
runBlocking {
|
executionService.tryTriggerWithPriceFromWs(
|
||||||
executionService.tryTriggerWithPriceFromWs(
|
strategy = e.strategy,
|
||||||
strategy = e.strategy,
|
periodStartUnix = e.periodStartUnix,
|
||||||
periodStartUnix = e.periodStartUnix,
|
marketTitle = e.marketTitle,
|
||||||
marketTitle = e.marketTitle,
|
tokenIds = e.tokenIds,
|
||||||
tokenIds = e.tokenIds,
|
outcomeIndex = e.outcomeIndex,
|
||||||
outcomeIndex = e.outcomeIndex,
|
bestBid = bestBid
|
||||||
bestBid = bestBid
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
logger.error("WS 触发下单异常: strategyId=${e.strategy.id}, ${ex.message}", ex)
|
logger.error("WS 触发下单异常: strategyId=${e.strategy.id}, ${ex.message}", ex)
|
||||||
}
|
}
|
||||||
@@ -209,42 +241,56 @@ class CryptoTailOrderbookWsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshAndSubscribe(fromConnect: Boolean = false) {
|
private fun refreshAndSubscribe(fromConnect: Boolean = false) {
|
||||||
periodEndCountdownJob?.cancel()
|
synchronized(refreshLock) {
|
||||||
periodEndCountdownJob = null
|
// 如果正在刷新,直接返回,避免重复调用
|
||||||
val oldTokenIds = tokenToEntries.get().keys.toSet()
|
if (isRefreshing.get()) {
|
||||||
val (tokenIds, newMap) = buildSubscriptionMap()
|
logger.debug("加密价差策略订阅刷新已在进行中,跳过本次调用")
|
||||||
tokenToEntries.set(newMap)
|
|
||||||
if (tokenIds.isEmpty()) {
|
|
||||||
closeWebSocketForNoStrategies()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!fromConnect) {
|
|
||||||
if (webSocket == null) {
|
|
||||||
connect()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (oldTokenIds == tokenIds.toSet()) {
|
isRefreshing.set(true)
|
||||||
scheduleRefreshAtPeriodEnd(newMap)
|
|
||||||
precomputeAutoMinSpreadForCurrentPeriods(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 {
|
try {
|
||||||
webSocket?.send(msg)
|
val strategies = strategyRepository.findAllByEnabledTrue()
|
||||||
logger.info("尾盘策略订单簿订阅: ${tokenIds.size} 个 token, 市场: $marketSlugs")
|
binanceKlineService.updateSubscriptions(strategies.map { it.marketSlugPrefix }.toSet())
|
||||||
} catch (e: Exception) {
|
periodEndCountdownJob?.cancel()
|
||||||
logger.warn("发送订阅失败: ${e.message}")
|
periodEndCountdownJob = null
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
scheduleRefreshAtPeriodEnd(newMap)
|
|
||||||
precomputeAutoMinSpreadForCurrentPeriods(newMap)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -257,37 +303,45 @@ class CryptoTailOrderbookWsService(
|
|||||||
try {
|
try {
|
||||||
ws.close(1000, "subscription_change")
|
ws.close(1000, "subscription_change")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.debug("关闭尾盘策略 WebSocket 时异常: ${e.message}")
|
logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}")
|
||||||
}
|
}
|
||||||
logger.info("尾盘策略订单簿 WebSocket 已关闭(订阅更新,将重连)")
|
logger.info("加密价差策略订单簿 WebSocket 已关闭(订阅更新,将重连)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 跟踪预计算价差的协程 Job,用于在关闭时取消 */
|
||||||
|
private val precomputeJobs = mutableSetOf<Job>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期最小价差,触发时直接用缓存。
|
* AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期价差,触发时直接用缓存。
|
||||||
*/
|
*/
|
||||||
private fun precomputeAutoMinSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
|
private fun precomputeAutoSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
|
||||||
val autoPeriods = newMap.values.asSequence().flatten()
|
val autoPeriods = newMap.values.asSequence().flatten()
|
||||||
.filter { it.strategy.minSpreadMode.uppercase() == "AUTO" }
|
.filter { it.strategy.spreadMode == SpreadMode.AUTO }
|
||||||
.distinctBy { "${it.strategy.intervalSeconds}-${it.periodStartUnix}" }
|
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.strategy.intervalSeconds}-${it.periodStartUnix}" }
|
||||||
.map { it.strategy.intervalSeconds to it.periodStartUnix }
|
.map { Triple(it.strategy.marketSlugPrefix, it.strategy.intervalSeconds, it.periodStartUnix) }
|
||||||
.toList()
|
.toList()
|
||||||
if (autoPeriods.isEmpty()) return
|
if (autoPeriods.isEmpty()) return
|
||||||
scope.launch {
|
val job = scope.launch {
|
||||||
for ((intervalSeconds, periodStartUnix) in autoPeriods) {
|
for ((marketPrefix, intervalSeconds, periodStartUnix) in autoPeriods) {
|
||||||
try {
|
try {
|
||||||
val pair = binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix)
|
val pair = binanceKlineAutoSpreadService.computeAndCache(marketPrefix, intervalSeconds, periodStartUnix)
|
||||||
if (pair != null) {
|
if (pair != null) {
|
||||||
logger.info(
|
logger.info(
|
||||||
"周期开始初始价差: interval=${intervalSeconds}s periodStartUnix=$periodStartUnix " +
|
"周期开始初始价差: market=$marketPrefix interval=${intervalSeconds}s periodStartUnix=$periodStartUnix " +
|
||||||
"baseSpreadUp=${pair.first.toPlainString()} baseSpreadDown=${pair.second.toPlainString()}"
|
"baseSpreadUp=${pair.first.toPlainString()} baseSpreadDown=${pair.second.toPlainString()}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("周期开始预计算 AUTO 价差失败: interval=$intervalSeconds periodStartUnix=$periodStartUnix ${e.message}")
|
logger.warn("周期开始预计算 AUTO 价差失败: market=$marketPrefix interval=$intervalSeconds periodStartUnix=$periodStartUnix ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
synchronized(precomputeJobs) {
|
||||||
|
precomputeJobs.add(job)
|
||||||
|
// 清理已完成的 Job,避免集合无限增长
|
||||||
|
precomputeJobs.removeIf { !it.isActive }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,9 +357,9 @@ class CryptoTailOrderbookWsService(
|
|||||||
try {
|
try {
|
||||||
ws.close(1000, "no_enabled_strategies")
|
ws.close(1000, "no_enabled_strategies")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.debug("关闭尾盘策略 WebSocket 时异常: ${e.message}")
|
logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}")
|
||||||
}
|
}
|
||||||
logger.info("尾盘策略订单簿 WebSocket 已关闭(无启用策略)")
|
logger.info("加密价差策略订单簿 WebSocket 已关闭(无启用策略)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +377,7 @@ class CryptoTailOrderbookWsService(
|
|||||||
periodEndCountdownJob = null
|
periodEndCountdownJob = null
|
||||||
refreshAndSubscribe()
|
refreshAndSubscribe()
|
||||||
}
|
}
|
||||||
logger.debug("尾盘策略订单簿订阅倒计时: ${delayMs / 1000}s 后刷新")
|
logger.debug("加密价差策略订单簿订阅倒计时: ${delayMs / 1000}s 后刷新")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSubscriptionMap(): Pair<List<String>, Map<String, List<WsBookEntry>>> {
|
private fun buildSubscriptionMap(): Pair<List<String>, Map<String, List<WsBookEntry>>> {
|
||||||
@@ -337,23 +391,23 @@ class CryptoTailOrderbookWsService(
|
|||||||
val periodStartUnix = (nowSeconds / interval) * interval
|
val periodStartUnix = (nowSeconds / interval) * interval
|
||||||
val windowEnd = periodStartUnix + strategy.windowEndSeconds
|
val windowEnd = periodStartUnix + strategy.windowEndSeconds
|
||||||
if (nowSeconds >= windowEnd) {
|
if (nowSeconds >= windowEnd) {
|
||||||
logger.debug("尾盘策略跳过(已过时间窗口): strategyId=${strategy.id}, slug=${strategy.marketSlugPrefix}, windowEnd=$windowEnd")
|
logger.debug("加密价差策略跳过(已过时间窗口): strategyId=${strategy.id}, slug=${strategy.marketSlugPrefix}, windowEnd=$windowEnd")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
||||||
val event = fetchEventBySlugWithRetry(slug).getOrNull()
|
val event = runBlocking { fetchEventBySlugWithRetry(slug).getOrNull() }
|
||||||
if (event == null) {
|
if (event == null) {
|
||||||
logger.warn("尾盘策略跳过(拉取事件失败): strategyId=${strategy.id}, slug=$slug,请确认 Gamma 是否存在该 slug 或稍后重试")
|
logger.warn("加密价差策略跳过(拉取事件失败): strategyId=${strategy.id}, slug=$slug,请确认 Gamma 是否存在该 slug 或稍后重试")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val market = event.markets?.firstOrNull()
|
val market = event.markets?.firstOrNull()
|
||||||
if (market == null) {
|
if (market == null) {
|
||||||
logger.warn("尾盘策略跳过(事件无市场): strategyId=${strategy.id}, slug=$slug")
|
logger.warn("加密价差策略跳过(事件无市场): strategyId=${strategy.id}, slug=$slug")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
||||||
if (tokenIds.size < 2) {
|
if (tokenIds.size < 2) {
|
||||||
logger.warn("尾盘策略跳过(token 数量不足): strategyId=${strategy.id}, slug=$slug, tokenCount=${tokenIds.size}")
|
logger.warn("加密价差策略跳过(token 数量不足): strategyId=${strategy.id}, slug=$slug, tokenCount=${tokenIds.size}")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tokenIdSet.addAll(tokenIds)
|
tokenIdSet.addAll(tokenIds)
|
||||||
@@ -368,21 +422,21 @@ class CryptoTailOrderbookWsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 拉取事件,失败时重试最多 2 次(间隔 1s),避免瞬时失败导致多策略只订阅到其中一个 */
|
/** 拉取事件,失败时重试最多 2 次(间隔 1s),避免瞬时失败导致多策略只订阅到其中一个 */
|
||||||
private fun fetchEventBySlugWithRetry(slug: String, maxAttempts: Int = 3): Result<GammaEventBySlugResponse> {
|
private suspend fun fetchEventBySlugWithRetry(slug: String, maxAttempts: Int = 3): Result<GammaEventBySlugResponse> {
|
||||||
var lastFailure: Exception? = null
|
var lastFailure: Exception? = null
|
||||||
repeat(maxAttempts) { attempt ->
|
repeat(maxAttempts) { attempt ->
|
||||||
val result = fetchEventBySlug(slug)
|
val result = fetchEventBySlug(slug)
|
||||||
if (result.isSuccess) return result
|
if (result.isSuccess) return result
|
||||||
lastFailure = result.exceptionOrNull() as? Exception
|
lastFailure = result.exceptionOrNull() as? Exception
|
||||||
if (attempt < maxAttempts - 1) runBlocking { delay(1000L) }
|
if (attempt < maxAttempts - 1) delay(1000L)
|
||||||
}
|
}
|
||||||
return Result.failure(lastFailure ?: Exception("fetchEventBySlug failed"))
|
return Result.failure(lastFailure ?: Exception("fetchEventBySlug failed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||||
return try {
|
return try {
|
||||||
val api = retrofitFactory.createGammaApi()
|
val api = retrofitFactory.createGammaApi()
|
||||||
val response = runBlocking { api.getEventBySlug(slug) }
|
val response = api.getEventBySlug(slug)
|
||||||
if (response.isSuccessful && response.body() != null) {
|
if (response.isSuccessful && response.body() != null) {
|
||||||
Result.success(response.body()!!)
|
Result.success(response.body()!!)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+24
-15
@@ -22,11 +22,12 @@ import org.slf4j.LoggerFactory
|
|||||||
import org.springframework.scheduling.annotation.Scheduled
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略结算轮询服务
|
* 加密价差策略结算轮询服务
|
||||||
* 定时扫描「状态成功但未结算」的触发记录,通过 Gamma 获取 conditionId、链上查询结算结果,计算收益并回写。
|
* 定时扫描「状态成功但未结算」的触发记录,通过 Gamma 获取 conditionId、链上查询结算结果,计算收益并回写。
|
||||||
* 实际成交价与成交量使用 Data API 的 activity 接口获取(getUserActivity),比 CLOB getOrder 更准确;失败时回退为触发时的 amountUsdc + 固定价 0.99。
|
* 实际成交价与成交量使用 Data API 的 activity 接口获取(getUserActivity),比 CLOB getOrder 更准确;失败时回退为触发时的 amountUsdc + 固定价 0.99。
|
||||||
*/
|
*/
|
||||||
@@ -44,7 +45,8 @@ class CryptoTailSettlementService(
|
|||||||
private val triggerFixedPrice = BigDecimal("0.99")
|
private val triggerFixedPrice = BigDecimal("0.99")
|
||||||
private val pnlScale = 8
|
private val pnlScale = 8
|
||||||
|
|
||||||
private val settlementScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val settlementScopeJob = SupervisorJob()
|
||||||
|
private val settlementScope = CoroutineScope(Dispatchers.IO + settlementScopeJob)
|
||||||
|
|
||||||
/** 跟踪上一轮结算任务的 Job,防止并发执行(与 OrderStatusUpdateService 一致) */
|
/** 跟踪上一轮结算任务的 Job,防止并发执行(与 OrderStatusUpdateService 一致) */
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -58,14 +60,14 @@ class CryptoTailSettlementService(
|
|||||||
fun scheduledPollAndSettle() {
|
fun scheduledPollAndSettle() {
|
||||||
val previousJob = settlementJob
|
val previousJob = settlementJob
|
||||||
if (previousJob != null && previousJob.isActive) {
|
if (previousJob != null && previousJob.isActive) {
|
||||||
logger.debug("上一轮尾盘结算任务仍在执行,跳过本次调度")
|
logger.debug("上一轮加密价差策略结算任务仍在执行,跳过本次调度")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
settlementJob = settlementScope.launch {
|
settlementJob = settlementScope.launch {
|
||||||
try {
|
try {
|
||||||
doPollAndSettle()
|
doPollAndSettle()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("尾盘策略结算定时任务异常: ${e.message}", e)
|
logger.error("加密价差策略结算定时任务异常: ${e.message}", e)
|
||||||
} finally {
|
} finally {
|
||||||
settlementJob = null
|
settlementJob = null
|
||||||
}
|
}
|
||||||
@@ -89,11 +91,11 @@ class CryptoTailSettlementService(
|
|||||||
try {
|
try {
|
||||||
if (settleOne(trigger)) settledCount++
|
if (settleOne(trigger)) settledCount++
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("尾盘结算单条失败: triggerId=${trigger.id}, ${e.message}", e)
|
logger.warn("加密价差策略结算单条失败: triggerId=${trigger.id}, ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (settledCount > 0) {
|
if (settledCount > 0) {
|
||||||
logger.info("尾盘策略结算轮询完成: 处理=${pending.size}, 新结算=$settledCount")
|
logger.info("加密价差策略结算轮询完成: 处理=${pending.size}, 新结算=$settledCount")
|
||||||
}
|
}
|
||||||
return settledCount
|
return settledCount
|
||||||
}
|
}
|
||||||
@@ -152,7 +154,7 @@ class CryptoTailSettlementService(
|
|||||||
settledAt = now
|
settledAt = now
|
||||||
)
|
)
|
||||||
triggerRepository.save(updated)
|
triggerRepository.save(updated)
|
||||||
logger.debug("尾盘结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl")
|
logger.debug("加密价差策略结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +201,7 @@ class CryptoTailSettlementService(
|
|||||||
conditionId: String
|
conditionId: String
|
||||||
): ActivityFill? {
|
): ActivityFill? {
|
||||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
||||||
logger.warn("尾盘结算未拉取 activity: 账户不存在, triggerId=${trigger.id}, accountId=${strategy.accountId}")
|
logger.warn("加密价差策略结算未拉取 activity: 账户不存在, triggerId=${trigger.id}, accountId=${strategy.accountId}")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val user = account.proxyAddress
|
val user = account.proxyAddress
|
||||||
@@ -218,7 +220,7 @@ class CryptoTailSettlementService(
|
|||||||
sortDirection = "DESC"
|
sortDirection = "DESC"
|
||||||
)
|
)
|
||||||
if (!response.isSuccessful || response.body() == null) {
|
if (!response.isSuccessful || response.body() == null) {
|
||||||
logger.warn("尾盘结算拉取 activity 失败: triggerId=${trigger.id}, code=${response.code()}")
|
logger.warn("加密价差策略结算拉取 activity 失败: triggerId=${trigger.id}, code=${response.code()}")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val activities = response.body()!!
|
val activities = response.body()!!
|
||||||
@@ -226,13 +228,13 @@ class CryptoTailSettlementService(
|
|||||||
val match = activities.firstOrNull { a ->
|
val match = activities.firstOrNull { a ->
|
||||||
a.type == "TRADE" &&
|
a.type == "TRADE" &&
|
||||||
a.conditionId == conditionId &&
|
a.conditionId == conditionId &&
|
||||||
a.outcomeIndex != null && a.outcomeIndex!! in 0..1 &&
|
a.outcomeIndex != null && a.outcomeIndex in 0..1 &&
|
||||||
a.outcomeIndex == trigger.outcomeIndex &&
|
a.outcomeIndex == trigger.outcomeIndex &&
|
||||||
a.side?.uppercase() == "BUY" &&
|
a.side?.uppercase() == "BUY" &&
|
||||||
a.price != null && a.price!! > 0 &&
|
a.price != null && a.price > 0 &&
|
||||||
a.size != null && a.size!! > 0
|
a.size != null && a.size > 0
|
||||||
} ?: run {
|
} ?: run {
|
||||||
logger.debug("尾盘结算 activity 无匹配成交: triggerId=${trigger.id}, conditionId=$conditionId, outcomeIndex=${trigger.outcomeIndex}, 条数=${activities.size}")
|
logger.debug("加密价差策略结算 activity 无匹配成交: triggerId=${trigger.id}, conditionId=$conditionId, outcomeIndex=${trigger.outcomeIndex}, 条数=${activities.size}")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val price = match.price!!.toSafeBigDecimal()
|
val price = match.price!!.toSafeBigDecimal()
|
||||||
@@ -241,11 +243,11 @@ class CryptoTailSettlementService(
|
|||||||
if (price.gt(BigDecimal.ZERO) && size.gt(BigDecimal.ZERO)) {
|
if (price.gt(BigDecimal.ZERO) && size.gt(BigDecimal.ZERO)) {
|
||||||
ActivityFill(price = price, size = size, usdcSize = usdcSize)
|
ActivityFill(price = price, size = size, usdcSize = usdcSize)
|
||||||
} else {
|
} else {
|
||||||
logger.debug("尾盘结算 activity 成交数据无效: triggerId=${trigger.id}, price=$price, size=$size")
|
logger.debug("加密价差策略结算 activity 成交数据无效: triggerId=${trigger.id}, price=$price, size=$size")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("尾盘结算拉取 activity 异常,触发价/投入金额不会更新: triggerId=${trigger.id}, error=${e.message}")
|
logger.warn("加密价差策略结算拉取 activity 异常,触发价/投入金额不会更新: triggerId=${trigger.id}, error=${e.message}")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,4 +275,11 @@ class CryptoTailSettlementService(
|
|||||||
amountUsdc.negate()
|
amountUsdc.negate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun destroy() {
|
||||||
|
settlementJob?.cancel()
|
||||||
|
settlementJob = null
|
||||||
|
settlementScopeJob.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+445
-67
@@ -3,9 +3,14 @@ package com.wrbug.polymarketbot.service.cryptotail
|
|||||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||||
import com.wrbug.polymarketbot.api.NewOrderRequest
|
import com.wrbug.polymarketbot.api.NewOrderRequest
|
||||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
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.Account
|
||||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
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.AccountRepository
|
||||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||||
@@ -26,14 +31,18 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
/** 尾盘策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
|
/** 加密价差策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
|
||||||
private const val TRIGGER_FIXED_PRICE = "0.99"
|
private const val TRIGGER_FIXED_PRICE = "0.99"
|
||||||
|
|
||||||
|
/** 最大价差模式(MAX)时,买入价格调整系数(加在触发价格上) */
|
||||||
|
private const val SPREAD_MAX_PRICE_ADJUSTMENT = "0.02"
|
||||||
|
|
||||||
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
|
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
|
||||||
private const val SIZE_DECIMAL_SCALE = 2
|
private const val SIZE_DECIMAL_SCALE = 2
|
||||||
|
|
||||||
@@ -56,7 +65,7 @@ private data class PeriodContext(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略执行服务:按周期与时间窗口检查价格并下单,每周期最多触发一次。
|
* 加密价差策略执行服务:按周期与时间窗口检查价格并下单,每周期最多触发一次。
|
||||||
* 周期开始预置账户、解密、费率、签名类型、CLOB 客户端;触发时按 outcomeIndex 计算 size 并签名提交。
|
* 周期开始预置账户、解密、费率、签名类型、CLOB 客户端;触发时按 outcomeIndex 计算 size 并签名提交。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -78,10 +87,25 @@ class CryptoTailStrategyExecutionService(
|
|||||||
/** 按 (strategyId, periodStartUnix) 加锁,避免同一周期被调度器与 WebSocket 等多路并发重复下单 */
|
/** 按 (strategyId, periodStartUnix) 加锁,避免同一周期被调度器与 WebSocket 等多路并发重复下单 */
|
||||||
private val triggerMutexMap = ConcurrentHashMap<String, Mutex>()
|
private val triggerMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||||
|
|
||||||
|
/** 过期锁 key 保留时间(秒),超过则清理,防止 map 无界增长 */
|
||||||
|
private val triggerMutexExpireSeconds = 3600L
|
||||||
|
|
||||||
private fun triggerLockKey(strategyId: Long, periodStartUnix: Long): String = "$strategyId-$periodStartUnix"
|
private fun triggerLockKey(strategyId: Long, periodStartUnix: Long): String = "$strategyId-$periodStartUnix"
|
||||||
|
|
||||||
private fun getTriggerMutex(strategyId: Long, periodStartUnix: Long): Mutex =
|
private fun getTriggerMutex(strategyId: Long, periodStartUnix: Long): Mutex {
|
||||||
triggerMutexMap.getOrPut(triggerLockKey(strategyId, periodStartUnix)) { 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,过期周期在读取时剔除 */
|
/** 周期预置上下文缓存:(strategyId-periodStartUnix) -> PeriodContext,过期周期在读取时剔除 */
|
||||||
private val periodContextCache = ConcurrentHashMap<String, PeriodContext>()
|
private val periodContextCache = ConcurrentHashMap<String, PeriodContext>()
|
||||||
@@ -110,17 +134,21 @@ class CryptoTailStrategyExecutionService(
|
|||||||
val decryptedKey = try {
|
val decryptedKey = try {
|
||||||
cryptoUtils.decrypt(account.privateKey) ?: return null
|
cryptoUtils.decrypt(account.privateKey) ?: return null
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("尾盘策略周期上下文解密私钥失败: accountId=${account.id}", e)
|
logger.warn("加密价差策略周期上下文解密私钥失败: accountId=${account.id}", e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val apiSecret = try {
|
val apiSecret = try {
|
||||||
account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: ""
|
account.apiSecret.let { cryptoUtils.decrypt(it) }
|
||||||
} catch (e: Exception) { "" }
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
val apiPassphrase = try {
|
val apiPassphrase = try {
|
||||||
account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: ""
|
account.apiPassphrase.let { cryptoUtils.decrypt(it) }
|
||||||
} catch (e: Exception) { "" }
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
|
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress)
|
||||||
val feeRateByTokenId = tokenIds.associate { tokenId ->
|
val feeRateByTokenId = tokenIds.associate { tokenId ->
|
||||||
tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0")
|
tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0")
|
||||||
}
|
}
|
||||||
@@ -160,11 +188,20 @@ class CryptoTailStrategyExecutionService(
|
|||||||
val ctx = periodContextCache[key] ?: return null
|
val ctx = periodContextCache[key] ?: return null
|
||||||
if (periodStartUnix + strategy.intervalSeconds <= nowSeconds) {
|
if (periodStartUnix + strategy.intervalSeconds <= nowSeconds) {
|
||||||
periodContextCache.remove(key)
|
periodContextCache.remove(key)
|
||||||
|
cleanExpiredPeriodContextCache(nowSeconds)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return ctx
|
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 且满足区间时调用,若本周期未触发则下单。
|
* 由订单簿 WebSocket 触发:当收到某 token 的 bestBid 且满足区间时调用,若本周期未触发则下单。
|
||||||
*/
|
*/
|
||||||
@@ -181,62 +218,96 @@ class CryptoTailStrategyExecutionService(
|
|||||||
|
|
||||||
val mutex = getTriggerMutex(strategy.id!!, periodStartUnix)
|
val mutex = getTriggerMutex(strategy.id!!, periodStartUnix)
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(strategy.id!!, periodStartUnix) != null) return@withLock
|
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(
|
||||||
|
strategy.id!!,
|
||||||
|
periodStartUnix
|
||||||
|
) != null
|
||||||
|
) return@withLock
|
||||||
val logKey = triggerLockKey(strategy.id!!, periodStartUnix)
|
val logKey = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||||
if (conditionLoggedCache.getIfPresent(logKey) == null) {
|
if (conditionLoggedCache.getIfPresent(logKey) == null) {
|
||||||
conditionLoggedCache.put(logKey, periodStartUnix + strategy.intervalSeconds)
|
conditionLoggedCache.put(logKey, periodStartUnix + strategy.intervalSeconds)
|
||||||
val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix)
|
val oc = binanceKlineService.getCurrentOpenClose(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
val openPrice = oc?.first?.toPlainString() ?: "-"
|
val openPrice = oc?.first?.toPlainString() ?: "-"
|
||||||
val closePrice = oc?.second?.toPlainString() ?: "-"
|
val closePrice = oc?.second?.toPlainString() ?: "-"
|
||||||
val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "尾盘策略-${strategy.marketSlugPrefix}"
|
val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "加密价差策略-${strategy.marketSlugPrefix}"
|
||||||
val direction = if (outcomeIndex == 0) "Up" else "Down"
|
val direction = if (outcomeIndex == 0) "Up" else "Down"
|
||||||
|
val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差"
|
||||||
logger.info(
|
logger.info(
|
||||||
"尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
|
"加密价差策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
|
||||||
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
|
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
|
||||||
"direction=$direction, outcomeIndex=$outcomeIndex"
|
"direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
|
if (!passSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
|
||||||
ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle)
|
ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle)
|
||||||
placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid)
|
placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun passMinSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
|
private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
|
||||||
val mode = strategy.minSpreadMode.uppercase()
|
if (strategy.spreadMode == SpreadMode.NONE) return true
|
||||||
if (mode == "NONE") return true
|
val oc = binanceKlineService.getCurrentOpenClose(
|
||||||
val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix)
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
?: return false
|
?: return false
|
||||||
val (openP, closeP) = oc
|
val (openP, closeP) = oc
|
||||||
val spreadAbs = closeP.subtract(openP).abs()
|
val spreadAbs = closeP.subtract(openP).abs()
|
||||||
when (mode) {
|
|
||||||
"FIXED" -> {
|
// 获取有效价差
|
||||||
val effectiveMinSpread = strategy.minSpreadValue?.takeIf { it > BigDecimal.ZERO }
|
val effectiveSpread = when (strategy.spreadMode) {
|
||||||
if (effectiveMinSpread == null || effectiveMinSpread <= BigDecimal.ZERO) return true
|
SpreadMode.FIXED -> {
|
||||||
return spreadAbs >= effectiveMinSpread
|
strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true
|
||||||
}
|
}
|
||||||
"AUTO" -> {
|
|
||||||
val result = computeAutoEffectiveMinSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
|
SpreadMode.AUTO -> {
|
||||||
val effectiveMinSpread = result.effectiveMinSpread
|
val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
|
||||||
if (effectiveMinSpread <= BigDecimal.ZERO) return true
|
result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true
|
||||||
return spreadAbs >= effectiveMinSpread
|
|
||||||
}
|
}
|
||||||
else -> return true
|
|
||||||
|
SpreadMode.NONE -> return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据价差方向判断
|
||||||
|
return if (strategy.spreadDirection == SpreadDirection.MAX) {
|
||||||
|
// 最大价差模式:价差 <= 配置值时触发
|
||||||
|
spreadAbs <= effectiveSpread
|
||||||
|
} else {
|
||||||
|
// 最小价差模式:价差 >= 配置值时触发
|
||||||
|
spreadAbs >= effectiveSpread
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AUTO 模式:取 100% 基准价差,按窗口内毫秒进度计算动态系数(100%→50%)得到有效最小价差。
|
* AUTO 模式:取 100% 基准价差,按窗口内毫秒进度计算动态系数(100%→50%)得到有效价差。
|
||||||
*/
|
*/
|
||||||
private data class AutoSpreadResult(
|
private data class AutoSpreadResult(
|
||||||
val baseSpread: BigDecimal,
|
val baseSpread: BigDecimal,
|
||||||
val coefficient: BigDecimal,
|
val coefficient: BigDecimal,
|
||||||
val effectiveMinSpread: BigDecimal
|
val effectiveSpread: BigDecimal
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun computeAutoEffectiveMinSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? {
|
private fun computeAutoEffectiveSpread(
|
||||||
val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.intervalSeconds, periodStartUnix, outcomeIndex)
|
strategy: CryptoTailStrategy,
|
||||||
?: binanceKlineAutoSpreadService.computeAndCache(strategy.intervalSeconds, periodStartUnix)?.let { if (outcomeIndex == 0) it.first else it.second }
|
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
|
?: return null
|
||||||
if (baseSpread <= BigDecimal.ZERO) return null
|
if (baseSpread <= BigDecimal.ZERO) return null
|
||||||
val windowStartMs = (periodStartUnix + strategy.windowStartSeconds) * 1000L
|
val windowStartMs = (periodStartUnix + strategy.windowStartSeconds) * 1000L
|
||||||
@@ -251,8 +322,8 @@ class CryptoTailStrategyExecutionService(
|
|||||||
.let { p -> maxOf(BigDecimal.ZERO, minOf(BigDecimal.ONE, p)) }
|
.let { p -> maxOf(BigDecimal.ZERO, minOf(BigDecimal.ONE, p)) }
|
||||||
BigDecimal.ONE.subtract(progress.multi("0.5"))
|
BigDecimal.ONE.subtract(progress.multi("0.5"))
|
||||||
}
|
}
|
||||||
val effectiveMinSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
|
val effectiveSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
|
||||||
return AutoSpreadResult(baseSpread, coefficient, effectiveMinSpread)
|
return AutoSpreadResult(baseSpread, coefficient, effectiveSpread)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun placeOrderForTrigger(
|
private suspend fun placeOrderForTrigger(
|
||||||
@@ -269,22 +340,52 @@ class CryptoTailStrategyExecutionService(
|
|||||||
val amountUsdc = when (strategy.amountMode.uppercase()) {
|
val amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||||
"RATIO" -> {
|
"RATIO" -> {
|
||||||
val balanceResult = accountService.getAccountBalance(ctx.account.id)
|
val balanceResult = accountService.getAccountBalance(ctx.account.id)
|
||||||
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
val availableBalance =
|
||||||
|
balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||||
availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> strategy.amountValue
|
else -> strategy.amountValue
|
||||||
}
|
}
|
||||||
if (amountUsdc < BigDecimal("1")) {
|
if (amountUsdc < BigDecimal("1")) {
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "投入金额不足")
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"投入金额不足"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"tokenIds 越界"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
// 根据价差方向确定下单价格
|
||||||
|
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 size = computeSize(amountUsdc, price)
|
||||||
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
|
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
|
||||||
val signedOrder = orderSigningService.createAndSignOrder(
|
val signedOrder = orderSigningService.createAndSignOrder(
|
||||||
@@ -292,7 +393,7 @@ class CryptoTailStrategyExecutionService(
|
|||||||
makerAddress = ctx.account.proxyAddress,
|
makerAddress = ctx.account.proxyAddress,
|
||||||
tokenId = tokenId,
|
tokenId = tokenId,
|
||||||
side = "BUY",
|
side = "BUY",
|
||||||
price = TRIGGER_FIXED_PRICE,
|
price = priceStr,
|
||||||
size = size,
|
size = size,
|
||||||
signatureType = ctx.signatureType,
|
signatureType = ctx.signatureType,
|
||||||
nonce = "0",
|
nonce = "0",
|
||||||
@@ -305,7 +406,17 @@ class CryptoTailStrategyExecutionService(
|
|||||||
orderType = "FAK",
|
orderType = "FAK",
|
||||||
deferExec = false
|
deferExec = false
|
||||||
)
|
)
|
||||||
submitOrderAndSaveRecord(ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest)
|
submitOrderAndSaveRecord(
|
||||||
|
ctx.clobApi,
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
orderRequest,
|
||||||
|
triggerType = "AUTO"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +431,8 @@ class CryptoTailStrategyExecutionService(
|
|||||||
outcomeIndex: Int,
|
outcomeIndex: Int,
|
||||||
triggerPrice: BigDecimal,
|
triggerPrice: BigDecimal,
|
||||||
amountUsdc: BigDecimal,
|
amountUsdc: BigDecimal,
|
||||||
orderRequest: NewOrderRequest
|
orderRequest: NewOrderRequest,
|
||||||
|
triggerType: String = "AUTO"
|
||||||
) {
|
) {
|
||||||
var failReason: String? = null
|
var failReason: String? = null
|
||||||
try {
|
try {
|
||||||
@@ -328,21 +440,43 @@ class CryptoTailStrategyExecutionService(
|
|||||||
if (response.isSuccessful && response.body() != null) {
|
if (response.isSuccessful && response.body() != null) {
|
||||||
val body = response.body()!!
|
val body = response.body()!!
|
||||||
if (body.success && body.orderId != null) {
|
if (body.success && body.orderId != null) {
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, body.orderId, "success", null)
|
saveTriggerRecord(
|
||||||
logger.info("尾盘策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
|
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
|
return
|
||||||
}
|
}
|
||||||
failReason = body.errorMsg ?: "unknown"
|
failReason = body.errorMsg ?: "unknown"
|
||||||
} else {
|
} else {
|
||||||
val errorBody = response.errorBody()?.string().orEmpty()
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
failReason = "HTTP ${response.code()} $errorBody"
|
failReason = errorBody.ifEmpty { "请求失败" }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
failReason = e.message ?: e.toString()
|
failReason = e.message ?: e.toString()
|
||||||
logger.error("尾盘策略下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
|
logger.error("加密价差策略下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
|
||||||
}
|
}
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", failReason)
|
saveTriggerRecord(
|
||||||
logger.error("尾盘策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason")
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
failReason,
|
||||||
|
triggerType = triggerType
|
||||||
|
)
|
||||||
|
logger.error("加密价差策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 无预置上下文时的完整流程:固定价格 0.99,账户/解密/费率/签名在触发时执行 */
|
/** 无预置上下文时的完整流程:固定价格 0.99,账户/解密/费率/签名在触发时执行 */
|
||||||
@@ -356,12 +490,32 @@ class CryptoTailStrategyExecutionService(
|
|||||||
) {
|
) {
|
||||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
||||||
logger.warn("账户不存在: accountId=${strategy.accountId}")
|
logger.warn("账户不存在: accountId=${strategy.accountId}")
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, BigDecimal.ZERO, null, "fail", "账户不存在")
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"账户不存在"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||||
logger.warn("账户未配置 API 凭证: accountId=${account.id}")
|
logger.warn("账户未配置 API 凭证: accountId=${account.id}")
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, BigDecimal.ZERO, null, "fail", "账户未配置API凭证")
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"账户未配置API凭证"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,31 +526,74 @@ class CryptoTailStrategyExecutionService(
|
|||||||
else -> strategy.amountValue
|
else -> strategy.amountValue
|
||||||
}
|
}
|
||||||
if (amountUsdc < BigDecimal("1")) {
|
if (amountUsdc < BigDecimal("1")) {
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "投入金额不足")
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"投入金额不足"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"tokenIds 越界"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
|
||||||
|
// 根据价差方向确定下单价格
|
||||||
|
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 size = computeSize(amountUsdc, price)
|
||||||
|
|
||||||
val decryptedKey = try {
|
val decryptedKey = try {
|
||||||
cryptoUtils.decrypt(account.privateKey) ?: ""
|
cryptoUtils.decrypt(account.privateKey) ?: ""
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("解密私钥失败: accountId=${account.id}", e)
|
logger.error("解密私钥失败: accountId=${account.id}", e)
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "解密私钥失败")
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"解密私钥失败"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val apiSecret = try {
|
val apiSecret = try {
|
||||||
account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: ""
|
account.apiSecret.let { cryptoUtils.decrypt(it) }
|
||||||
} catch (e: Exception) { "" }
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
val apiPassphrase = try {
|
val apiPassphrase = try {
|
||||||
account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: ""
|
account.apiPassphrase.let { cryptoUtils.decrypt(it) }
|
||||||
} catch (e: Exception) { "" }
|
} catch (e: Exception) {
|
||||||
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
|
""
|
||||||
|
}
|
||||||
|
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress)
|
||||||
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
|
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
|
||||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||||
|
|
||||||
@@ -405,7 +602,7 @@ class CryptoTailStrategyExecutionService(
|
|||||||
makerAddress = account.proxyAddress,
|
makerAddress = account.proxyAddress,
|
||||||
tokenId = tokenId,
|
tokenId = tokenId,
|
||||||
side = "BUY",
|
side = "BUY",
|
||||||
price = TRIGGER_FIXED_PRICE,
|
price = priceStr,
|
||||||
size = size,
|
size = size,
|
||||||
signatureType = signatureType,
|
signatureType = signatureType,
|
||||||
nonce = "0",
|
nonce = "0",
|
||||||
@@ -418,7 +615,16 @@ class CryptoTailStrategyExecutionService(
|
|||||||
orderType = "FAK",
|
orderType = "FAK",
|
||||||
deferExec = false
|
deferExec = false
|
||||||
)
|
)
|
||||||
submitOrderAndSaveRecord(clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest)
|
submitOrderAndSaveRecord(
|
||||||
|
clobApi,
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
orderRequest
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||||
@@ -451,7 +657,8 @@ class CryptoTailStrategyExecutionService(
|
|||||||
amountUsdc: BigDecimal,
|
amountUsdc: BigDecimal,
|
||||||
orderId: String?,
|
orderId: String?,
|
||||||
status: String,
|
status: String,
|
||||||
failReason: String?
|
failReason: String?,
|
||||||
|
triggerType: String = "AUTO"
|
||||||
) {
|
) {
|
||||||
val record = CryptoTailStrategyTrigger(
|
val record = CryptoTailStrategyTrigger(
|
||||||
strategyId = strategy.id!!,
|
strategyId = strategy.id!!,
|
||||||
@@ -462,8 +669,179 @@ class CryptoTailStrategyExecutionService(
|
|||||||
amountUsdc = amountUsdc,
|
amountUsdc = amountUsdc,
|
||||||
orderId = orderId,
|
orderId = orderId,
|
||||||
status = status,
|
status = status,
|
||||||
failReason = failReason
|
failReason = failReason,
|
||||||
|
triggerType = triggerType
|
||||||
)
|
)
|
||||||
triggerRepository.save(record)
|
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 USDC"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
|
||||||
|
|
||||||
|
val signedOrder = orderSigningService.createAndSignOrder(
|
||||||
|
privateKey = ctx.decryptedPrivateKey,
|
||||||
|
makerAddress = ctx.account.proxyAddress,
|
||||||
|
tokenId = tokenId,
|
||||||
|
side = "BUY",
|
||||||
|
price = priceStr,
|
||||||
|
size = sizeStr,
|
||||||
|
signatureType = ctx.signatureType,
|
||||||
|
nonce = "0",
|
||||||
|
feeRateBps = feeRateBps,
|
||||||
|
expiration = "0"
|
||||||
|
)
|
||||||
|
|
||||||
|
val orderRequest = NewOrderRequest(
|
||||||
|
order = signedOrder,
|
||||||
|
owner = ctx.account.apiKey!!,
|
||||||
|
orderType = "FAK",
|
||||||
|
deferExec = false
|
||||||
|
)
|
||||||
|
|
||||||
|
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("加密价差策略执行服务已清理缓存和锁")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-20
@@ -4,9 +4,12 @@ import com.wrbug.polymarketbot.dto.*
|
|||||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
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.CryptoTailStrategyRepository
|
||||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||||
|
import com.wrbug.polymarketbot.util.gt
|
||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -62,12 +65,18 @@ class CryptoTailStrategyService(
|
|||||||
if (amountValue <= BigDecimal.ZERO) {
|
if (amountValue <= BigDecimal.ZERO) {
|
||||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||||
}
|
}
|
||||||
val minSpreadMode = (request.minSpreadMode ?: "NONE").uppercase()
|
val spreadMode = try {
|
||||||
if (minSpreadMode != "NONE" && minSpreadMode != "FIXED" && minSpreadMode != "AUTO") {
|
SpreadMode.fromString(request.spreadMode)
|
||||||
|
} catch (e: Exception) {
|
||||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||||
}
|
}
|
||||||
val minSpreadValue = request.minSpreadValue?.toSafeBigDecimal()
|
val spreadValue = request.spreadValue?.toSafeBigDecimal()
|
||||||
if (minSpreadMode == "FIXED" && (minSpreadValue == null || minSpreadValue < BigDecimal.ZERO)) {
|
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))
|
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +94,9 @@ class CryptoTailStrategyService(
|
|||||||
maxPrice = maxPrice,
|
maxPrice = maxPrice,
|
||||||
amountMode = amountMode,
|
amountMode = amountMode,
|
||||||
amountValue = amountValue,
|
amountValue = amountValue,
|
||||||
minSpreadMode = minSpreadMode,
|
spreadMode = spreadMode,
|
||||||
minSpreadValue = minSpreadValue,
|
spreadValue = spreadValue,
|
||||||
|
spreadDirection = spreadDirection,
|
||||||
enabled = request.enabled
|
enabled = request.enabled
|
||||||
)
|
)
|
||||||
val saved = strategyRepository.save(entity)
|
val saved = strategyRepository.save(entity)
|
||||||
@@ -95,7 +105,7 @@ class CryptoTailStrategyService(
|
|||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("创建尾盘策略失败: ${e.message}", e)
|
logger.error("创建加密价差策略失败: ${e.message}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,13 +131,27 @@ class CryptoTailStrategyService(
|
|||||||
?: existing.name?.takeIf { it.isNotBlank() }
|
?: existing.name?.takeIf { it.isNotBlank() }
|
||||||
?: generateStrategyName(existing.marketSlugPrefix)
|
?: generateStrategyName(existing.marketSlugPrefix)
|
||||||
|
|
||||||
val newMinSpreadMode = request.minSpreadMode?.uppercase() ?: existing.minSpreadMode
|
val newSpreadMode = if (request.spreadMode != null) {
|
||||||
if (newMinSpreadMode != "NONE" && newMinSpreadMode != "FIXED" && newMinSpreadMode != "AUTO") {
|
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))
|
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||||
}
|
}
|
||||||
val newMinSpreadValue = request.minSpreadValue?.toSafeBigDecimal() ?: existing.minSpreadValue
|
val newSpreadDirection = if (request.spreadDirection != null) {
|
||||||
if (newMinSpreadMode == "FIXED" && (newMinSpreadValue == null || newMinSpreadValue < BigDecimal.ZERO)) {
|
try {
|
||||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
SpreadDirection.fromString(request.spreadDirection)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
existing.spreadDirection
|
||||||
}
|
}
|
||||||
|
|
||||||
val updated = existing.copy(
|
val updated = existing.copy(
|
||||||
@@ -138,8 +162,9 @@ class CryptoTailStrategyService(
|
|||||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice,
|
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice,
|
||||||
amountMode = request.amountMode?.uppercase() ?: existing.amountMode,
|
amountMode = request.amountMode?.uppercase() ?: existing.amountMode,
|
||||||
amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue,
|
amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue,
|
||||||
minSpreadMode = newMinSpreadMode,
|
spreadMode = newSpreadMode,
|
||||||
minSpreadValue = newMinSpreadValue,
|
spreadValue = newSpreadValue,
|
||||||
|
spreadDirection = newSpreadDirection,
|
||||||
enabled = request.enabled ?: existing.enabled,
|
enabled = request.enabled ?: existing.enabled,
|
||||||
updatedAt = System.currentTimeMillis()
|
updatedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
@@ -155,7 +180,7 @@ class CryptoTailStrategyService(
|
|||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("更新尾盘策略失败: ${e.message}", e)
|
logger.error("更新加密价差策略失败: ${e.message}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +195,7 @@ class CryptoTailStrategyService(
|
|||||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("删除尾盘策略失败: ${e.message}", e)
|
logger.error("删除加密价差策略失败: ${e.message}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +216,62 @@ class CryptoTailStrategyService(
|
|||||||
val dtos = list.map { entityToDto(it, lastTriggerMap[it.id]) }
|
val dtos = list.map { entityToDto(it, lastTriggerMap[it.id]) }
|
||||||
Result.success(CryptoTailStrategyListResponse(list = dtos))
|
Result.success(CryptoTailStrategyListResponse(list = dtos))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("查询尾盘策略列表失败: ${e.message}", e)
|
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)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,7 +319,7 @@ class CryptoTailStrategyService(
|
|||||||
private fun generateStrategyName(marketSlugPrefix: String): String {
|
private fun generateStrategyName(marketSlugPrefix: String): String {
|
||||||
val suffix = Instant.now().atZone(ZoneId.systemDefault())
|
val suffix = Instant.now().atZone(ZoneId.systemDefault())
|
||||||
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
||||||
return "尾盘策略-${marketSlugPrefix}-$suffix"
|
return "加密价差策略-${marketSlugPrefix}-$suffix"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun entityToDto(e: CryptoTailStrategy, lastTriggerAt: Long?): CryptoTailStrategyDto {
|
private fun entityToDto(e: CryptoTailStrategy, lastTriggerAt: Long?): CryptoTailStrategyDto {
|
||||||
@@ -263,8 +343,9 @@ class CryptoTailStrategyService(
|
|||||||
maxPrice = e.maxPrice.toPlainString(),
|
maxPrice = e.maxPrice.toPlainString(),
|
||||||
amountMode = e.amountMode,
|
amountMode = e.amountMode,
|
||||||
amountValue = e.amountValue.toPlainString(),
|
amountValue = e.amountValue.toPlainString(),
|
||||||
minSpreadMode = e.minSpreadMode,
|
spreadMode = e.spreadMode.name,
|
||||||
minSpreadValue = e.minSpreadValue?.toPlainString(),
|
spreadValue = e.spreadValue?.toPlainString(),
|
||||||
|
spreadDirection = e.spreadDirection.name,
|
||||||
enabled = e.enabled,
|
enabled = e.enabled,
|
||||||
lastTriggerAt = lastTriggerAt,
|
lastTriggerAt = lastTriggerAt,
|
||||||
totalRealizedPnl = totalPnl?.toPlainString(),
|
totalRealizedPnl = totalPnl?.toPlainString(),
|
||||||
|
|||||||
+8
-1
@@ -243,7 +243,14 @@ class ApiHealthCheckService(
|
|||||||
name = "币安 WebSocket",
|
name = "币安 WebSocket",
|
||||||
url = binanceWsUrl,
|
url = binanceWsUrl,
|
||||||
status = "success",
|
status = "success",
|
||||||
message = "连接正常 (5m、15m)"
|
message = "连接正常 (按策略订阅)"
|
||||||
|
)
|
||||||
|
} else if (total == 0) {
|
||||||
|
ApiHealthCheckDto(
|
||||||
|
name = "币安 WebSocket",
|
||||||
|
url = binanceWsUrl,
|
||||||
|
status = "success",
|
||||||
|
message = "无加密价差策略,未订阅"
|
||||||
)
|
)
|
||||||
} else if (connected > 0) {
|
} else if (connected > 0) {
|
||||||
val which = statuses.filter { it.value }.keys.joinToString("、")
|
val which = statuses.filter { it.value }.keys.joinToString("、")
|
||||||
|
|||||||
+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> USDC
|
||||||
|
• 账户: {{account_name}}
|
||||||
|
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||||
|
|
||||||
|
⏰ 时间: <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> USDC
|
||||||
|
• 账户: {{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> USDC
|
||||||
|
• 账户: {{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> USDC
|
||||||
|
• 账户: {{account_name}}
|
||||||
|
|
||||||
|
⏰ 时间: <code>{{time}}</code>
|
||||||
|
""".trimIndent(),
|
||||||
|
"REDEEM_SUCCESS" to """
|
||||||
|
💸 <b>仓位赎回成功</b>
|
||||||
|
|
||||||
|
📊 <b>赎回信息:</b>
|
||||||
|
• 账户: {{account_name}}
|
||||||
|
• 交易哈希: <code>{{transaction_hash}}</code>
|
||||||
|
• 赎回总价值: <code>{{total_value}}</code> USDC
|
||||||
|
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||||
|
|
||||||
|
⏰ 时间: <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> USDC
|
||||||
|
|
||||||
|
⏰ 时间: <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
|
||||||
|
)
|
||||||
|
}
|
||||||
+321
-20
@@ -5,12 +5,16 @@ import com.wrbug.polymarketbot.api.EthereumRpcApi
|
|||||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.enums.WalletType
|
import com.wrbug.polymarketbot.enums.WalletType
|
||||||
|
import com.wrbug.polymarketbot.util.Eip712Encoder
|
||||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import retrofit2.Response
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RelayClient 服务
|
* RelayClient 服务
|
||||||
@@ -20,8 +24,8 @@ import java.math.BigInteger
|
|||||||
* 如果需要真正的 Gasless 功能,需要集成 Builder Relayer API
|
* 如果需要真正的 Gasless 功能,需要集成 Builder Relayer API
|
||||||
*
|
*
|
||||||
* 参考:
|
* 参考:
|
||||||
* - TypeScript: @polymarket/builder-relayer-client
|
* - TypeScript: https://github.com/Polymarket/builder-relayer-client(client.execute、src/encode/safe.ts MultiSend)
|
||||||
* - TypeScript: utils/redeem.ts
|
* - 赎回 calldata 由本服务构建,官方仓库无 redeem 工具;Neg Risk 逻辑见 docs/neg-risk-redeem.md
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class RelayClientService(
|
class RelayClientService(
|
||||||
@@ -35,9 +39,12 @@ class RelayClientService(
|
|||||||
// ConditionalTokens 合约地址
|
// ConditionalTokens 合约地址
|
||||||
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||||
|
|
||||||
// USDC.e 合约地址
|
// USDC.e 合约地址(普通市场抵押品)
|
||||||
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||||
|
|
||||||
|
// Neg Risk 市场使用的 WrappedCollateral 合约地址(Polygon,neg-risk-ctf-adapter)
|
||||||
|
private val negRiskWrappedCollateralAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
|
||||||
|
|
||||||
// 空集合ID
|
// 空集合ID
|
||||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
|
||||||
@@ -45,16 +52,76 @@ class RelayClientService(
|
|||||||
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
|
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
|
||||||
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
|
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
|
||||||
private val defaultProxyGasLimit = "10000000"
|
private val defaultProxyGasLimit = "10000000"
|
||||||
|
|
||||||
|
// Safe MultiSend 合约地址(Polygon 主网)
|
||||||
|
private val safeMultisendAddress = "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761"
|
||||||
|
|
||||||
// Builder Relayer API 交易类型常量
|
// Builder Relayer API 交易类型常量
|
||||||
private val RELAYER_TYPE_PROXY = "PROXY"
|
private val RELAYER_TYPE_PROXY = "PROXY"
|
||||||
private val RELAYER_TYPE_SAFE = "SAFE"
|
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 {
|
private val polygonRpcApi: EthereumRpcApi by lazy {
|
||||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||||
retrofitFactory.createEthereumRpcApi(rpcUrl)
|
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 客户端(动态获取,因为配置可能更新)
|
* 获取 Builder Relayer API 客户端(动态获取,因为配置可能更新)
|
||||||
*/
|
*/
|
||||||
@@ -125,6 +192,7 @@ class RelayClientService(
|
|||||||
Result.success(responseTime)
|
Result.success(responseTime)
|
||||||
} else {
|
} else {
|
||||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||||
|
updateQuotaBlockedFromErrorBody(errorBody)
|
||||||
Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
|
Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -170,19 +238,22 @@ class RelayClientService(
|
|||||||
/**
|
/**
|
||||||
* 创建赎回交易(支持多个 indexSets,用于批量赎回)
|
* 创建赎回交易(支持多个 indexSets,用于批量赎回)
|
||||||
* 参考 TypeScript: utils/redeem.ts 的 createRedeemTx
|
* 参考 TypeScript: utils/redeem.ts 的 createRedeemTx
|
||||||
|
* Neg Risk 市场使用 WrappedCollateral 作为抵押品,需传 isNegRisk=true
|
||||||
*
|
*
|
||||||
* @param conditionId 市场条件ID
|
* @param conditionId 市场条件ID
|
||||||
* @param indexSets 索引集合列表(每个元素是 2^outcomeIndex)
|
* @param indexSets 索引集合列表(每个元素是 2^outcomeIndex)
|
||||||
|
* @param isNegRisk 是否为 Neg Risk 市场(true 时使用 WrappedCollateral 地址)
|
||||||
* @return Safe 交易对象
|
* @return Safe 交易对象
|
||||||
*/
|
*/
|
||||||
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>): SafeTransaction {
|
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>, isNegRisk: Boolean = false): SafeTransaction {
|
||||||
// 编码 redeemPositions 函数调用
|
// 编码 redeemPositions 函数调用
|
||||||
val functionSelector = EthereumUtils.getFunctionSelector(
|
val functionSelector = EthereumUtils.getFunctionSelector(
|
||||||
"redeemPositions(address,bytes32,bytes32,uint256[])"
|
"redeemPositions(address,bytes32,bytes32,uint256[])"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 编码参数
|
// Neg Risk 市场仓位由 WrappedCollateral 抵押,普通市场由 USDC 抵押
|
||||||
val encodedCollateral = EthereumUtils.encodeAddress(usdcContractAddress)
|
val collateralAddress = if (isNegRisk) negRiskWrappedCollateralAddress else usdcContractAddress
|
||||||
|
val encodedCollateral = EthereumUtils.encodeAddress(collateralAddress)
|
||||||
val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET)
|
val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET)
|
||||||
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
|
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
|
||||||
|
|
||||||
@@ -210,6 +281,115 @@ class RelayClientService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 WCOL 解包交易(将 Wrapped Collateral 解包为 USDC.e)
|
||||||
|
* 合约: Neg Risk WrappedCollateral 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2
|
||||||
|
* 方法: unwrap(address _to, uint256 _amount),解包后 USDC.e 转到 _to
|
||||||
|
*
|
||||||
|
* Safe 与 Magic 共用此交易对象:Safe 走 [executeViaBuilderRelayer] / [executeManually](execTransaction),
|
||||||
|
* Magic 走 [executeViaBuilderRelayerProxy](encodeProxyTransactionData),语义一致。
|
||||||
|
*
|
||||||
|
* @param toAddress 接收 USDC.e 的地址(通常为 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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 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)
|
* 执行代理交易(Safe 或 Magic PROXY)
|
||||||
* 参考 TypeScript: RelayClient.execute()
|
* 参考 TypeScript: RelayClient.execute()
|
||||||
@@ -294,9 +474,10 @@ class RelayClientService(
|
|||||||
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
|
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
|
||||||
val fromAddress = credentials.address
|
val fromAddress = credentials.address
|
||||||
|
|
||||||
val relayPayloadResponse = relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY)
|
val relayPayloadResponse = withBuilderRelayerRateLimitRetry { relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY) }
|
||||||
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
|
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
|
||||||
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
|
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
|
||||||
|
updateQuotaBlockedFromErrorBody(errorBody)
|
||||||
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
|
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
|
||||||
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
|
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
|
||||||
}
|
}
|
||||||
@@ -360,9 +541,10 @@ class RelayClientService(
|
|||||||
metadata = "Redeem positions via Builder Relayer PROXY"
|
metadata = "Redeem positions via Builder Relayer PROXY"
|
||||||
)
|
)
|
||||||
|
|
||||||
val response = relayerApi.submitTransaction(request)
|
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
|
||||||
if (!response.isSuccessful || response.body() == null) {
|
if (!response.isSuccessful || response.body() == null) {
|
||||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||||
|
updateQuotaBlockedFromErrorBody(errorBody)
|
||||||
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
|
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
|
||||||
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
|
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
|
||||||
}
|
}
|
||||||
@@ -524,15 +706,27 @@ class RelayClientService(
|
|||||||
// safeTx.data 已经是带 0x 前缀的完整调用数据
|
// safeTx.data 已经是带 0x 前缀的完整调用数据
|
||||||
val redeemCallData = safeTx.data
|
val redeemCallData = safeTx.data
|
||||||
|
|
||||||
// 获取 Proxy 的 nonce(通过 Builder Relayer API)
|
// 获取 Proxy 的 nonce(通过 Builder Relayer API,遇 429 限流时重试)
|
||||||
val nonceResponse = relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE)
|
val nonceResponse = withBuilderRelayerRateLimitRetry { relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE) }
|
||||||
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
|
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
|
||||||
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
|
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
|
||||||
|
updateQuotaBlockedFromErrorBody(errorBody)
|
||||||
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
|
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
|
||||||
return Result.failure(Exception("获取 nonce 失败: ${nonceResponse.code()} - $errorBody"))
|
return Result.failure(Exception("获取 nonce 失败: ${nonceResponse.code()} - $errorBody"))
|
||||||
}
|
}
|
||||||
val proxyNonce = BigInteger(nonceResponse.body()!!.nonce)
|
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 交易哈希并签名
|
// 构建 Safe 交易哈希并签名
|
||||||
// 注意:encodeSafeTx 需要 data 带 0x 前缀
|
// 注意:encodeSafeTx 需要 data 带 0x 前缀
|
||||||
val safeTxGas = BigInteger.ZERO
|
val safeTxGas = BigInteger.ZERO
|
||||||
@@ -564,6 +758,12 @@ class RelayClientService(
|
|||||||
messageHash = safeTxHash
|
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 前缀
|
// 注意:ethers.js 的 signMessage 会添加 EIP-191 前缀
|
||||||
// 格式:\x19Ethereum Signed Message:\n<length><message>
|
// 格式:\x19Ethereum Signed Message:\n<length><message>
|
||||||
// 我们需要模拟这个行为以匹配 TypeScript 实现
|
// 我们需要模拟这个行为以匹配 TypeScript 实现
|
||||||
@@ -578,17 +778,17 @@ class RelayClientService(
|
|||||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||||
keccak256.doFinal(hashWithPrefix, 0)
|
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 ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||||
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||||
|
|
||||||
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||||
val packedSignature = splitAndPackSig(safeSignature)
|
val packedSignature = splitAndPackSig(safeSignature)
|
||||||
|
|
||||||
// 调试日志(地址已遮蔽)
|
|
||||||
logger.debug("=== Builder Relayer 签名调试 ===")
|
|
||||||
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
|
|
||||||
logger.debug("Signature Length: ${packedSignature.length}")
|
|
||||||
|
|
||||||
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts)
|
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts)
|
||||||
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
|
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
|
||||||
val request = BuilderRelayerApi.TransactionRequest(
|
val request = BuilderRelayerApi.TransactionRequest(
|
||||||
@@ -607,16 +807,19 @@ class RelayClientService(
|
|||||||
gasToken = gasToken,
|
gasToken = gasToken,
|
||||||
refundReceiver = refundReceiver
|
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(认证头通过拦截器添加,遇 429 限流时重试)
|
||||||
|
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
|
||||||
// 调用 Builder Relayer API(认证头通过拦截器添加)
|
|
||||||
val response = relayerApi.submitTransaction(request)
|
|
||||||
|
|
||||||
if (!response.isSuccessful || response.body() == null) {
|
if (!response.isSuccessful || response.body() == null) {
|
||||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||||
|
updateQuotaBlockedFromErrorBody(errorBody)
|
||||||
logger.error("Builder Relayer API 调用失败: code=${response.code()}, body=$errorBody")
|
logger.error("Builder Relayer API 调用失败: code=${response.code()}, body=$errorBody")
|
||||||
return Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
|
return Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
|
||||||
}
|
}
|
||||||
@@ -629,6 +832,104 @@ class RelayClientService(
|
|||||||
return Result.success(txHash)
|
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)
|
* 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||||
* 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
|
* 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
|
||||||
|
|||||||
+4
-17
@@ -25,19 +25,6 @@ class SystemConfigService(
|
|||||||
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
|
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
|
||||||
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
|
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
|
||||||
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
|
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 builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
|
||||||
val autoRedeem = isAutoRedeemEnabled()
|
val autoRedeem = isAutoRedeemEnabled()
|
||||||
|
|
||||||
// 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置)
|
// 获取完整显示值(用于前端展示与编辑)
|
||||||
val builderApiKeyDisplay = builderApiKey?.let {
|
val builderApiKeyDisplay = builderApiKey?.let {
|
||||||
try {
|
try {
|
||||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
cryptoUtils.decrypt(it)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -60,7 +47,7 @@ class SystemConfigService(
|
|||||||
|
|
||||||
val builderSecretDisplay = builderSecret?.let {
|
val builderSecretDisplay = builderSecret?.let {
|
||||||
try {
|
try {
|
||||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
cryptoUtils.decrypt(it)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -68,7 +55,7 @@ class SystemConfigService(
|
|||||||
|
|
||||||
val builderPassphraseDisplay = builderPassphrase?.let {
|
val builderPassphraseDisplay = builderPassphrase?.let {
|
||||||
try {
|
try {
|
||||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
cryptoUtils.decrypt(it)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
+500
-35
@@ -26,6 +26,7 @@ import java.util.concurrent.TimeUnit
|
|||||||
@Service
|
@Service
|
||||||
class TelegramNotificationService(
|
class TelegramNotificationService(
|
||||||
private val notificationConfigService: NotificationConfigService,
|
private val notificationConfigService: NotificationConfigService,
|
||||||
|
private val notificationTemplateService: NotificationTemplateService,
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
private val messageSource: MessageSource
|
private val messageSource: MessageSource
|
||||||
) {
|
) {
|
||||||
@@ -85,7 +86,9 @@ class TelegramNotificationService(
|
|||||||
marketId: String? = null,
|
marketId: String? = null,
|
||||||
marketSlug: String? = null,
|
marketSlug: String? = null,
|
||||||
side: String,
|
side: String,
|
||||||
price: String? = null, // 订单价格(可选,如果提供则直接使用)
|
price: String? = null, // 订单限价(可选)
|
||||||
|
avgFilledPrice: String? = null, // 平均成交价(可选,有成交时优先展示)
|
||||||
|
filled: String? = null, // 已成交数量(可选,与 avgFilledPrice 一起时用于金额计算)
|
||||||
size: String? = null, // 订单数量(可选,如果提供则直接使用)
|
size: String? = null, // 订单数量(可选,如果提供则直接使用)
|
||||||
outcome: String? = null, // 市场方向(可选,如果提供则直接使用)
|
outcome: String? = null, // 市场方向(可选,如果提供则直接使用)
|
||||||
accountName: String? = null,
|
accountName: String? = null,
|
||||||
@@ -98,7 +101,8 @@ class TelegramNotificationService(
|
|||||||
locale: java.util.Locale? = null,
|
locale: java.util.Locale? = null,
|
||||||
leaderName: String? = null, // Leader 名称(备注)
|
leaderName: String? = null, // Leader 名称(备注)
|
||||||
configName: String? = null, // 跟单配置名
|
configName: String? = null, // 跟单配置名
|
||||||
orderTime: Long? = null // 订单创建时间(毫秒时间戳),用于通知中的时间显示
|
orderTime: Long? = null, // 订单创建时间(毫秒时间戳),用于通知中的时间显示
|
||||||
|
availableBalance: String? = null // 可用余额(可选)
|
||||||
) {
|
) {
|
||||||
// 1. 如果提供了 orderId,检查是否已发送过通知(去重)
|
// 1. 如果提供了 orderId,检查是否已发送过通知(去重)
|
||||||
if (orderId != null) {
|
if (orderId != null) {
|
||||||
@@ -128,14 +132,21 @@ class TelegramNotificationService(
|
|||||||
java.util.Locale("zh", "CN") // 默认简体中文
|
java.util.Locale("zh", "CN") // 默认简体中文
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先使用传入的价格和数量,如果没有提供则尝试从订单详情获取
|
// 优先使用平均成交价(实际成交价),其次传入的限价,若未提供则从订单详情获取
|
||||||
var actualPrice: String? = price
|
var actualPrice: String? = avgFilledPrice?.takeIf { it.isNotBlank() } ?: price
|
||||||
var actualSize: String? = size
|
var actualSize: String? = size
|
||||||
var actualSide: String = side
|
var actualSide: String = side
|
||||||
var actualOutcome: String? = outcome
|
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 {
|
try {
|
||||||
val orderResponse = clobApi.getOrder(orderId)
|
val orderResponse = clobApi.getOrder(orderId)
|
||||||
if (orderResponse.isSuccessful) {
|
if (orderResponse.isSuccessful) {
|
||||||
@@ -147,7 +158,8 @@ class TelegramNotificationService(
|
|||||||
if (actualSize == null) {
|
if (actualSize == null) {
|
||||||
actualSize = order.originalSize // 使用 originalSize 作为订单数量
|
actualSize = order.originalSize // 使用 originalSize 作为订单数量
|
||||||
}
|
}
|
||||||
actualSide = order.side // 使用订单详情中的 side
|
// 注意:不覆盖 side,因为传入的 side(BUY/SELL)是正确的
|
||||||
|
// actualSide = order.side // 不要使用订单详情中的 side,因为它可能不准确
|
||||||
if (actualOutcome == null) {
|
if (actualOutcome == null) {
|
||||||
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
|
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
|
||||||
}
|
}
|
||||||
@@ -165,19 +177,28 @@ class TelegramNotificationService(
|
|||||||
|
|
||||||
// 如果仍然没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留)
|
// 如果仍然没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留)
|
||||||
val finalPrice = actualPrice ?: "0"
|
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)
|
// 计算订单金额 = price × size(USDC)
|
||||||
val amount = try {
|
val amount = try {
|
||||||
val priceDecimal = finalPrice.toSafeBigDecimal()
|
val priceDecimal = finalPrice.toSafeBigDecimal()
|
||||||
val sizeDecimal = finalSize.toSafeBigDecimal()
|
val sizeDecimal = sizeForCalc.toSafeBigDecimal()
|
||||||
priceDecimal.multiply(sizeDecimal).toString()
|
priceDecimal.multiply(sizeDecimal).toString()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("计算订单金额失败: ${e.message}", e)
|
logger.warn("计算订单金额失败: ${e.message}", e)
|
||||||
null
|
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,
|
orderId = orderId,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketId = marketId,
|
marketId = marketId,
|
||||||
@@ -192,8 +213,12 @@ class TelegramNotificationService(
|
|||||||
locale = currentLocale,
|
locale = currentLocale,
|
||||||
leaderName = leaderName,
|
leaderName = leaderName,
|
||||||
configName = configName,
|
configName = configName,
|
||||||
orderTime = orderTime
|
orderTime = orderTime,
|
||||||
|
availableBalance = availableBalance,
|
||||||
|
unknownAccount = unknownAccount,
|
||||||
|
calculateFailed = calculateFailed
|
||||||
)
|
)
|
||||||
|
val message = notificationTemplateService.renderTemplate("ORDER_SUCCESS", vars)
|
||||||
sendMessage(message)
|
sendMessage(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +257,9 @@ class TelegramNotificationService(
|
|||||||
null
|
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,
|
marketTitle = marketTitle,
|
||||||
marketId = marketId,
|
marketId = marketId,
|
||||||
marketSlug = marketSlug,
|
marketSlug = marketSlug,
|
||||||
@@ -244,11 +271,65 @@ class TelegramNotificationService(
|
|||||||
errorMessage = errorMessage,
|
errorMessage = errorMessage,
|
||||||
accountName = accountName,
|
accountName = accountName,
|
||||||
walletAddress = walletAddress,
|
walletAddress = walletAddress,
|
||||||
locale = currentLocale
|
locale = currentLocale,
|
||||||
|
unknownAccount = unknownAccount,
|
||||||
|
calculateFailed = calculateFailed
|
||||||
)
|
)
|
||||||
|
val message = notificationTemplateService.renderTemplate("ORDER_FAILED", vars)
|
||||||
sendMessage(message)
|
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 获取)
|
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||||
@@ -285,7 +366,9 @@ class TelegramNotificationService(
|
|||||||
null
|
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,
|
marketTitle = marketTitle,
|
||||||
marketId = marketId,
|
marketId = marketId,
|
||||||
marketSlug = marketSlug,
|
marketSlug = marketSlug,
|
||||||
@@ -298,13 +381,75 @@ class TelegramNotificationService(
|
|||||||
filterType = filterType,
|
filterType = filterType,
|
||||||
accountName = accountName,
|
accountName = accountName,
|
||||||
walletAddress = walletAddress,
|
walletAddress = walletAddress,
|
||||||
locale = currentLocale
|
locale = currentLocale,
|
||||||
|
unknownAccount = unknownAccount,
|
||||||
|
calculateFailed = calculateFailed
|
||||||
)
|
)
|
||||||
|
val message = notificationTemplateService.renderTemplate("ORDER_FILTERED", vars)
|
||||||
sendMessage(message)
|
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 订单推送时匹配尾盘订单后调用)
|
* 发送加密价差策略下单成功通知(与跟单一致:在收到 WS 订单推送时匹配价差策略订单后调用)
|
||||||
|
* @param price 订单限价
|
||||||
|
* @param avgFilledPrice 平均成交价(可选,有成交时优先展示)
|
||||||
|
* @param filled 已成交数量(可选,与 avgFilledPrice 一起时用于金额计算)
|
||||||
*/
|
*/
|
||||||
suspend fun sendCryptoTailOrderSuccessNotification(
|
suspend fun sendCryptoTailOrderSuccessNotification(
|
||||||
orderId: String?,
|
orderId: String?,
|
||||||
@@ -315,6 +460,8 @@ class TelegramNotificationService(
|
|||||||
outcome: String? = null,
|
outcome: String? = null,
|
||||||
price: String,
|
price: String,
|
||||||
size: String,
|
size: String,
|
||||||
|
avgFilledPrice: String? = null,
|
||||||
|
filled: String? = null,
|
||||||
strategyName: String? = null,
|
strategyName: String? = null,
|
||||||
accountName: String? = null,
|
accountName: String? = null,
|
||||||
walletAddress: String? = null,
|
walletAddress: String? = null,
|
||||||
@@ -324,7 +471,7 @@ class TelegramNotificationService(
|
|||||||
if (orderId != null) {
|
if (orderId != null) {
|
||||||
val lastSentTime = sentOrderIds[orderId]
|
val lastSentTime = sentOrderIds[orderId]
|
||||||
if (lastSentTime != null && System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) {
|
if (lastSentTime != null && System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) {
|
||||||
logger.info("尾盘订单通知已发送过(5分钟内),跳过: orderId=$orderId")
|
logger.info("加密价差策略订单通知已发送过(5分钟内),跳过: orderId=$orderId")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sentOrderIds[orderId] = System.currentTimeMillis()
|
sentOrderIds[orderId] = System.currentTimeMillis()
|
||||||
@@ -339,33 +486,95 @@ class TelegramNotificationService(
|
|||||||
logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e)
|
logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e)
|
||||||
java.util.Locale("zh", "CN")
|
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 amount = try {
|
||||||
val priceDecimal = price.toSafeBigDecimal()
|
val priceDecimal = displayPrice.toSafeBigDecimal()
|
||||||
val sizeDecimal = size.toSafeBigDecimal()
|
val sizeDecimal = sizeForAmount.toSafeBigDecimal()
|
||||||
priceDecimal.multiply(sizeDecimal).toString()
|
priceDecimal.multiply(sizeDecimal).toString()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("计算订单金额失败: ${e.message}", e)
|
logger.warn("计算订单金额失败: ${e.message}", e)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val message = buildCryptoTailOrderSuccessMessage(
|
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,
|
orderId = orderId,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketId = marketId,
|
marketId = marketId,
|
||||||
marketSlug = marketSlug,
|
marketSlug = marketSlug,
|
||||||
side = side,
|
side = side,
|
||||||
outcome = outcome,
|
outcome = outcome,
|
||||||
price = price,
|
price = displayPrice,
|
||||||
size = size,
|
size = quantityDisplay.orEmpty(),
|
||||||
amount = amount,
|
amount = amount,
|
||||||
strategyName = strategyName,
|
strategyName = strategyName,
|
||||||
accountName = accountName,
|
accountName = accountName,
|
||||||
walletAddress = walletAddress,
|
walletAddress = walletAddress,
|
||||||
locale = currentLocale,
|
orderTime = orderTime,
|
||||||
orderTime = orderTime
|
unknown = unknown,
|
||||||
|
unknownAccount = unknownAccount,
|
||||||
|
calculateFailed = calculateFailed,
|
||||||
|
locale = currentLocale
|
||||||
)
|
)
|
||||||
|
val message = notificationTemplateService.renderTemplate("CRYPTO_TAIL_SUCCESS", vars)
|
||||||
sendMessage(message)
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建订单被过滤消息
|
* 构建订单被过滤消息
|
||||||
*/
|
*/
|
||||||
@@ -748,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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建订单成功消息
|
* 构建订单成功消息
|
||||||
*/
|
*/
|
||||||
@@ -766,7 +1045,8 @@ class TelegramNotificationService(
|
|||||||
locale: java.util.Locale,
|
locale: java.util.Locale,
|
||||||
leaderName: String? = null, // Leader 名称(备注)
|
leaderName: String? = null, // Leader 名称(备注)
|
||||||
configName: String? = null, // 跟单配置名
|
configName: String? = null, // 跟单配置名
|
||||||
orderTime: Long? = null // 订单创建时间(毫秒时间戳)
|
orderTime: Long? = null, // 订单创建时间(毫秒时间戳)
|
||||||
|
availableBalance: String? = null // 可用余额
|
||||||
): String {
|
): String {
|
||||||
|
|
||||||
// 获取多语言文本
|
// 获取多语言文本
|
||||||
@@ -781,6 +1061,7 @@ class TelegramNotificationService(
|
|||||||
val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
|
val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
|
||||||
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
|
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
|
||||||
val timeLabel = messageSource.getMessage("notification.order.time", 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 unknown = messageSource.getMessage("common.unknown", null, "未知", locale)
|
||||||
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale)
|
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale)
|
||||||
@@ -879,6 +1160,23 @@ class TelegramNotificationService(
|
|||||||
val priceDisplay = formatPrice(price)
|
val priceDisplay = formatPrice(price)
|
||||||
val sizeDisplay = formatQuantity(size)
|
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> USDC"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"\n• $availableBalanceLabel: <code>$availableBalance</code> USDC"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
return """$icon <b>$orderCreatedSuccess</b>
|
return """$icon <b>$orderCreatedSuccess</b>
|
||||||
|
|
||||||
📊 <b>$orderInfo:</b>
|
📊 <b>$orderInfo:</b>
|
||||||
@@ -888,13 +1186,13 @@ class TelegramNotificationService(
|
|||||||
• $priceLabel: <code>$priceDisplay</code>
|
• $priceLabel: <code>$priceDisplay</code>
|
||||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||||
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
|
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo$availableBalanceDisplay
|
||||||
|
|
||||||
⏰ $timeLabel: <code>$time</code>"""
|
⏰ $timeLabel: <code>$time</code>"""
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建尾盘策略下单成功消息(与订单成功格式一致,增加「尾盘策略」标题与策略名)
|
* 构建加密价差策略下单成功消息(与订单成功格式一致,增加「加密价差策略」标题与策略名)
|
||||||
*/
|
*/
|
||||||
private fun buildCryptoTailOrderSuccessMessage(
|
private fun buildCryptoTailOrderSuccessMessage(
|
||||||
orderId: String?,
|
orderId: String?,
|
||||||
@@ -912,7 +1210,7 @@ class TelegramNotificationService(
|
|||||||
locale: java.util.Locale,
|
locale: java.util.Locale,
|
||||||
orderTime: Long?
|
orderTime: Long?
|
||||||
): String {
|
): String {
|
||||||
val tailOrderSuccess = messageSource.getMessage("notification.tail.order.success", null, "尾盘策略下单成功", locale)
|
val tailOrderSuccess = messageSource.getMessage("notification.tail.order.success", null, "加密价差策略下单成功", locale)
|
||||||
val strategyLabel = messageSource.getMessage("notification.tail.strategy", null, "策略", locale)
|
val strategyLabel = messageSource.getMessage("notification.tail.strategy", null, "策略", locale)
|
||||||
val orderInfo = messageSource.getMessage("notification.order.info", null, "订单信息", locale)
|
val orderInfo = messageSource.getMessage("notification.order.info", null, "订单信息", locale)
|
||||||
val orderIdLabel = messageSource.getMessage("notification.order.id", null, "订单ID", locale)
|
val orderIdLabel = messageSource.getMessage("notification.order.id", null, "订单ID", locale)
|
||||||
@@ -1095,6 +1393,7 @@ class TelegramNotificationService(
|
|||||||
/**
|
/**
|
||||||
* 发送仓位赎回通知
|
* 发送仓位赎回通知
|
||||||
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||||
|
* @param availableBalance 可用余额(可选)
|
||||||
*/
|
*/
|
||||||
suspend fun sendRedeemNotification(
|
suspend fun sendRedeemNotification(
|
||||||
accountName: String?,
|
accountName: String?,
|
||||||
@@ -1102,7 +1401,8 @@ class TelegramNotificationService(
|
|||||||
transactionHash: String,
|
transactionHash: String,
|
||||||
totalRedeemedValue: String,
|
totalRedeemedValue: String,
|
||||||
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
||||||
locale: java.util.Locale? = null
|
locale: java.util.Locale? = null,
|
||||||
|
availableBalance: String? = null
|
||||||
) {
|
) {
|
||||||
// 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取)
|
// 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取)
|
||||||
val currentLocale = locale ?: try {
|
val currentLocale = locale ?: try {
|
||||||
@@ -1112,16 +1412,46 @@ class TelegramNotificationService(
|
|||||||
java.util.Locale("zh", "CN") // 默认简体中文
|
java.util.Locale("zh", "CN") // 默认简体中文
|
||||||
}
|
}
|
||||||
|
|
||||||
val message = buildRedeemMessage(
|
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale) ?: "未知账户"
|
||||||
|
val vars = buildRedeemSuccessVariables(
|
||||||
accountName = accountName,
|
accountName = accountName,
|
||||||
walletAddress = walletAddress,
|
walletAddress = walletAddress,
|
||||||
transactionHash = transactionHash,
|
transactionHash = transactionHash,
|
||||||
totalRedeemedValue = totalRedeemedValue,
|
totalRedeemedValue = totalRedeemedValue,
|
||||||
positions = positions,
|
availableBalance = availableBalance,
|
||||||
locale = currentLocale
|
unknownAccount = unknownAccount
|
||||||
)
|
)
|
||||||
|
val message = notificationTemplateService.renderTemplate("REDEEM_SUCCESS", vars)
|
||||||
sendMessage(message)
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建仓位赎回消息
|
* 构建仓位赎回消息
|
||||||
@@ -1132,7 +1462,8 @@ class TelegramNotificationService(
|
|||||||
transactionHash: String,
|
transactionHash: String,
|
||||||
totalRedeemedValue: String,
|
totalRedeemedValue: String,
|
||||||
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
||||||
locale: java.util.Locale
|
locale: java.util.Locale,
|
||||||
|
availableBalance: String? = null
|
||||||
): String {
|
): String {
|
||||||
// 获取多语言文本
|
// 获取多语言文本
|
||||||
val redeemSuccess = messageSource.getMessage("notification.redeem.success", null, "仓位赎回成功", locale)
|
val redeemSuccess = messageSource.getMessage("notification.redeem.success", null, "仓位赎回成功", locale)
|
||||||
@@ -1145,6 +1476,7 @@ class TelegramNotificationService(
|
|||||||
val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale)
|
val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale)
|
||||||
val valueLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
|
val valueLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
|
||||||
val timeLabel = messageSource.getMessage("notification.order.time", 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 unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||||
|
|
||||||
// 构建账户信息(格式:账户名(钱包地址))
|
// 构建账户信息(格式:账户名(钱包地址))
|
||||||
@@ -1186,19 +1518,152 @@ class TelegramNotificationService(
|
|||||||
" • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC"
|
" • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式化可用余额
|
||||||
|
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> USDC"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"\n• $availableBalanceLabel: <code>$availableBalance</code> USDC"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
return """💸 <b>$redeemSuccess</b>
|
return """💸 <b>$redeemSuccess</b>
|
||||||
|
|
||||||
📊 <b>$redeemInfo:</b>
|
📊 <b>$redeemInfo:</b>
|
||||||
• $accountLabel: $escapedAccountInfo
|
• $accountLabel: $escapedAccountInfo
|
||||||
• $transactionHashLabel: <code>$escapedTxHash</code>
|
• $transactionHashLabel: <code>$escapedTxHash</code>
|
||||||
• $totalValueLabel: <code>$totalValueDisplay</code> USDC
|
• $totalValueLabel: <code>$totalValueDisplay</code> USDC$availableBalanceDisplay
|
||||||
|
|
||||||
📦 <b>$positionsLabel:</b>
|
📦 <b>$positionsLabel:</b>
|
||||||
$positionsText
|
$positionsText
|
||||||
|
|
||||||
⏰ $timeLabel: <code>$time</code>"""
|
⏰ $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> USDC"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"\n• $availableBalanceLabel: <code>$availableBalance</code> USDC"
|
||||||
|
}
|
||||||
|
} 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位)
|
* 脱敏显示地址(只显示前6位和后4位)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -377,5 +377,62 @@ object Eip712Encoder {
|
|||||||
|
|
||||||
return keccak256(encoded)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -400,8 +400,6 @@ class RpcUrlReplaceInterceptor(
|
|||||||
private val fixedBaseUrl: String,
|
private val fixedBaseUrl: String,
|
||||||
private val actualRpcUrl: String
|
private val actualRpcUrl: String
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
private val logger = LoggerFactory.getLogger(RpcUrlReplaceInterceptor::class.java)
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val originalRequest = chain.request()
|
val originalRequest = chain.request()
|
||||||
@@ -415,8 +413,6 @@ class RpcUrlReplaceInterceptor(
|
|||||||
val newUrl = newUrlString.toHttpUrlOrNull()
|
val newUrl = newUrlString.toHttpUrlOrNull()
|
||||||
?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString")
|
?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString")
|
||||||
|
|
||||||
logger.debug("RPC URL 替换: $originalUrlString -> $newUrlString")
|
|
||||||
|
|
||||||
val newRequest = originalRequest.newBuilder()
|
val newRequest = originalRequest.newBuilder()
|
||||||
.url(newUrl)
|
.url(newUrl)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
+1
-2
@@ -193,7 +193,7 @@ class UnifiedWebSocketHandler(
|
|||||||
lastActivityTime.remove(sessionId)
|
lastActivityTime.remove(sessionId)
|
||||||
sessionLocks.remove(sessionId) // 清理同步锁
|
sessionLocks.remove(sessionId) // 清理同步锁
|
||||||
subscriptionService.unregisterSession(sessionId)
|
subscriptionService.unregisterSession(sessionId)
|
||||||
|
|
||||||
if (session != null && session.isOpen) {
|
if (session != null && session.isOpen) {
|
||||||
try {
|
try {
|
||||||
session.close(CloseStatus.NORMAL)
|
session.close(CloseStatus.NORMAL)
|
||||||
@@ -201,7 +201,6 @@ class UnifiedWebSocketHandler(
|
|||||||
// 忽略关闭时的异常
|
// 忽略关闭时的异常
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("清理 WebSocket 资源时发生错误: $sessionId, ${e.message}", e)
|
logger.error("清理 WebSocket 资源时发生错误: $sessionId, ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +13,19 @@ notification.order.quantity=Quantity
|
|||||||
notification.order.amount=Amount
|
notification.order.amount=Amount
|
||||||
notification.order.account=Account
|
notification.order.account=Account
|
||||||
notification.order.time=Time
|
notification.order.time=Time
|
||||||
|
notification.order.available_balance=Available Balance
|
||||||
notification.order.error_info=Error Information
|
notification.order.error_info=Error Information
|
||||||
notification.order.unknown_account=Unknown Account
|
notification.order.unknown_account=Unknown Account
|
||||||
notification.order.calculate_failed=Calculation Failed
|
notification.order.calculate_failed=Calculation Failed
|
||||||
notification.tail.order.success=Tail Session Order Success
|
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.tail.strategy=Strategy
|
||||||
notification.redeem.success=Position Redeemed Successfully
|
notification.redeem.success=Position Redeemed Successfully
|
||||||
notification.redeem.info=Redeem Information
|
notification.redeem.info=Redeem Information
|
||||||
@@ -26,6 +35,13 @@ notification.redeem.position_count=Position Count
|
|||||||
notification.redeem.positions=Redeemed Positions
|
notification.redeem.positions=Redeemed Positions
|
||||||
notification.redeem.account=Account
|
notification.redeem.account=Account
|
||||||
notification.redeem.time=Time
|
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
|
# Auto Redeem related notifications
|
||||||
notification.auto_redeem.disabled.title=Auto Redeem Disabled
|
notification.auto_redeem.disabled.title=Auto Redeem Disabled
|
||||||
@@ -277,16 +293,16 @@ error.server.backtest_stop_failed=Failed to stop backtest task
|
|||||||
error.server.backtest_retry_failed=Failed to retry 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
|
error.server.backtest_rerun_failed=Failed to re-run backtest with same config
|
||||||
|
|
||||||
# Crypto tail strategy
|
# Crypto spread strategy
|
||||||
error.crypto_tail_strategy_not_found=Crypto tail strategy not found
|
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_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_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_interval_invalid=Interval must be 300 or 900 seconds
|
||||||
error.crypto_tail_strategy_amount_mode_invalid=Amount mode must be RATIO or FIXED
|
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 tail strategy
|
error.server.crypto_tail_strategy_create_failed=Failed to create crypto spread strategy
|
||||||
error.server.crypto_tail_strategy_update_failed=Failed to update crypto tail 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 tail 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 tail strategy list
|
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
|
error.server.crypto_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records
|
||||||
# Backtest Management
|
# Backtest Management
|
||||||
backtest.title=Backtest Management
|
backtest.title=Backtest Management
|
||||||
|
|||||||
@@ -13,10 +13,19 @@ notification.order.quantity=数量
|
|||||||
notification.order.amount=金额
|
notification.order.amount=金额
|
||||||
notification.order.account=账户
|
notification.order.account=账户
|
||||||
notification.order.time=时间
|
notification.order.time=时间
|
||||||
|
notification.order.available_balance=可用余额
|
||||||
notification.order.error_info=错误信息
|
notification.order.error_info=错误信息
|
||||||
notification.order.unknown_account=未知账户
|
notification.order.unknown_account=未知账户
|
||||||
notification.order.calculate_failed=计算失败
|
notification.order.calculate_failed=计算失败
|
||||||
notification.tail.order.success=尾盘策略下单成功
|
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.tail.strategy=策略
|
||||||
notification.redeem.success=仓位赎回成功
|
notification.redeem.success=仓位赎回成功
|
||||||
notification.redeem.info=赎回信息
|
notification.redeem.info=赎回信息
|
||||||
@@ -26,6 +35,13 @@ notification.redeem.position_count=仓位数量
|
|||||||
notification.redeem.positions=赎回仓位
|
notification.redeem.positions=赎回仓位
|
||||||
notification.redeem.account=账户
|
notification.redeem.account=账户
|
||||||
notification.redeem.time=时间
|
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=自动赎回未开启
|
notification.auto_redeem.disabled.title=自动赎回未开启
|
||||||
@@ -277,16 +293,16 @@ error.server.backtest_stop_failed=停止回测任务失败
|
|||||||
error.server.backtest_retry_failed=重试回测任务失败
|
error.server.backtest_retry_failed=重试回测任务失败
|
||||||
error.server.backtest_rerun_failed=按配置重新测试失败
|
error.server.backtest_rerun_failed=按配置重新测试失败
|
||||||
|
|
||||||
# 尾盘策略
|
# 加密价差策略
|
||||||
error.crypto_tail_strategy_not_found=尾盘策略不存在
|
error.crypto_tail_strategy_not_found=加密价差策略不存在
|
||||||
error.crypto_tail_strategy_window_invalid=时间区间开始不能大于结束
|
error.crypto_tail_strategy_window_invalid=时间区间开始不能大于结束
|
||||||
error.crypto_tail_strategy_window_exceed=时间区间不能超过周期长度
|
error.crypto_tail_strategy_window_exceed=时间区间不能超过周期长度
|
||||||
error.crypto_tail_strategy_interval_invalid=周期仅支持 300 或 900 秒
|
error.crypto_tail_strategy_interval_invalid=周期仅支持 300 或 900 秒
|
||||||
error.crypto_tail_strategy_amount_mode_invalid=投入方式仅支持 RATIO 或 FIXED
|
error.crypto_tail_strategy_amount_mode_invalid=投入方式仅支持 RATIO 或 FIXED
|
||||||
error.server.crypto_tail_strategy_create_failed=创建尾盘策略失败
|
error.server.crypto_tail_strategy_create_failed=创建加密价差策略失败
|
||||||
error.server.crypto_tail_strategy_update_failed=更新尾盘策略失败
|
error.server.crypto_tail_strategy_update_failed=更新加密价差策略失败
|
||||||
error.server.crypto_tail_strategy_delete_failed=删除尾盘策略失败
|
error.server.crypto_tail_strategy_delete_failed=删除加密价差策略失败
|
||||||
error.server.crypto_tail_strategy_list_fetch_failed=查询尾盘策略列表失败
|
error.server.crypto_tail_strategy_list_fetch_failed=查询加密价差策略列表失败
|
||||||
error.server.crypto_tail_strategy_triggers_fetch_failed=查询触发记录失败
|
error.server.crypto_tail_strategy_triggers_fetch_failed=查询触发记录失败
|
||||||
# 回测管理
|
# 回测管理
|
||||||
backtest.title=回测管理
|
backtest.title=回测管理
|
||||||
|
|||||||
@@ -13,10 +13,19 @@ notification.order.quantity=數量
|
|||||||
notification.order.amount=金額
|
notification.order.amount=金額
|
||||||
notification.order.account=賬戶
|
notification.order.account=賬戶
|
||||||
notification.order.time=時間
|
notification.order.time=時間
|
||||||
|
notification.order.available_balance=可用餘額
|
||||||
notification.order.error_info=錯誤信息
|
notification.order.error_info=錯誤信息
|
||||||
notification.order.unknown_account=未知賬戶
|
notification.order.unknown_account=未知賬戶
|
||||||
notification.order.calculate_failed=計算失敗
|
notification.order.calculate_failed=計算失敗
|
||||||
notification.tail.order.success=尾盤策略下單成功
|
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.tail.strategy=策略
|
||||||
notification.redeem.success=倉位贖回成功
|
notification.redeem.success=倉位贖回成功
|
||||||
notification.redeem.info=贖回信息
|
notification.redeem.info=贖回信息
|
||||||
@@ -26,6 +35,13 @@ notification.redeem.position_count=倉位數量
|
|||||||
notification.redeem.positions=贖回倉位
|
notification.redeem.positions=贖回倉位
|
||||||
notification.redeem.account=賬戶
|
notification.redeem.account=賬戶
|
||||||
notification.redeem.time=時間
|
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=自動贖回未開啟
|
notification.auto_redeem.disabled.title=自動贖回未開啟
|
||||||
@@ -277,16 +293,16 @@ error.server.backtest_stop_failed=停止回測任務失敗
|
|||||||
error.server.backtest_retry_failed=重試回測任務失敗
|
error.server.backtest_retry_failed=重試回測任務失敗
|
||||||
error.server.backtest_rerun_failed=依配置重新測試失敗
|
error.server.backtest_rerun_failed=依配置重新測試失敗
|
||||||
|
|
||||||
# 尾盤策略
|
# 加密價差策略
|
||||||
error.crypto_tail_strategy_not_found=尾盤策略不存在
|
error.crypto_tail_strategy_not_found=加密價差策略不存在
|
||||||
error.crypto_tail_strategy_window_invalid=時間區間開始不能大於結束
|
error.crypto_tail_strategy_window_invalid=時間區間開始不能大於結束
|
||||||
error.crypto_tail_strategy_window_exceed=時間區間不能超過週期長度
|
error.crypto_tail_strategy_window_exceed=時間區間不能超過週期長度
|
||||||
error.crypto_tail_strategy_interval_invalid=週期僅支援 300 或 900 秒
|
error.crypto_tail_strategy_interval_invalid=週期僅支援 300 或 900 秒
|
||||||
error.crypto_tail_strategy_amount_mode_invalid=投入方式僅支援 RATIO 或 FIXED
|
error.crypto_tail_strategy_amount_mode_invalid=投入方式僅支援 RATIO 或 FIXED
|
||||||
error.server.crypto_tail_strategy_create_failed=創建尾盤策略失敗
|
error.server.crypto_tail_strategy_create_failed=創建加密價差策略失敗
|
||||||
error.server.crypto_tail_strategy_update_failed=更新尾盤策略失敗
|
error.server.crypto_tail_strategy_update_failed=更新加密價差策略失敗
|
||||||
error.server.crypto_tail_strategy_delete_failed=刪除尾盤策略失敗
|
error.server.crypto_tail_strategy_delete_failed=刪除加密價差策略失敗
|
||||||
error.server.crypto_tail_strategy_list_fetch_failed=查詢尾盤策略列表失敗
|
error.server.crypto_tail_strategy_list_fetch_failed=查詢加密價差策略列表失敗
|
||||||
error.server.crypto_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗
|
error.server.crypto_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗
|
||||||
# 回測管理
|
# 回測管理
|
||||||
backtest.title=回測管理
|
backtest.title=回測管理
|
||||||
|
|||||||
@@ -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
|
#!/bin/bash
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
|
# PolyHermes Interactive Deploy Script
|
||||||
# PolyHermes 交互式一键部署脚本
|
# PolyHermes 交互式一键部署脚本
|
||||||
# ========================================
|
# ========================================
|
||||||
# 功能:
|
# Features / 功能:
|
||||||
# - 交互式配置环境变量
|
# - Interactive env config / 交互式配置环境变量
|
||||||
# - 自动生成安全密钥
|
# - Auto-generate secrets / 自动生成安全密钥
|
||||||
# - 使用 Docker Hub 线上镜像部署
|
# - Deploy via Docker Hub images / 使用 Docker Hub 线上镜像部署
|
||||||
# - 支持配置预检和回滚
|
# - Config check and rollback / 支持配置预检和回滚
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# 颜色输出
|
# Colors / 颜色输出
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
@@ -20,7 +21,14 @@ BLUE='\033[0;34m'
|
|||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
NC='\033[0m' # No Color
|
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() {
|
info() {
|
||||||
echo -e "${GREEN}[✓]${NC} $1"
|
echo -e "${GREEN}[✓]${NC} $1"
|
||||||
}
|
}
|
||||||
@@ -37,6 +45,17 @@ title() {
|
|||||||
echo -e "${CYAN}${1}${NC}"
|
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() {
|
generate_secret() {
|
||||||
local length=${1:-32}
|
local length=${1:-32}
|
||||||
@@ -47,54 +66,57 @@ generate_secret() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 生成随机端口号(10000-60000之间)
|
# 生成随机端口号(10000-60000之间)/ Generate random port (10000-60000)
|
||||||
generate_random_port() {
|
generate_random_port() {
|
||||||
echo $((10000 + RANDOM % 50001))
|
echo $((10000 + RANDOM % 50001))
|
||||||
}
|
}
|
||||||
|
|
||||||
# 读取用户输入(支持默认值)
|
# 读取用户输入(支持默认值)/ Read user input (with default)
|
||||||
read_input() {
|
read_input() {
|
||||||
local prompt="$1"
|
local prompt="$1"
|
||||||
local default="$2"
|
local default="$2"
|
||||||
local is_secret="$3"
|
local is_secret="$3"
|
||||||
local value=""
|
local value=""
|
||||||
|
|
||||||
# 构建提示信息(不使用颜色,因为 read -p 可能不支持)
|
|
||||||
local prompt_text=""
|
local prompt_text=""
|
||||||
if [ -n "$default" ]; then
|
if [ -n "$default" ]; then
|
||||||
if [ "$is_secret" = "secret" ]; 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
|
else
|
||||||
prompt_text="${prompt} [默认: ${default}]: "
|
if [ "$USE_ZH_ONLY" = true ]; then
|
||||||
|
prompt_text="${prompt} [默认: ${default}]: "
|
||||||
|
else
|
||||||
|
prompt_text="${prompt} [Default: ${default}]: "
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
prompt_text="${prompt}: "
|
prompt_text="${prompt}: "
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 使用 read -p 确保提示正确显示
|
|
||||||
read -r -p "$prompt_text" value
|
read -r -p "$prompt_text" value
|
||||||
|
|
||||||
# 如果用户没有输入,使用默认值
|
|
||||||
if [ -z "$value" ]; then
|
if [ -z "$value" ]; then
|
||||||
if [ "$is_secret" = "secret" ] && [ -z "$default" ]; then
|
if [ "$is_secret" = "secret" ] && [ -z "$default" ]; then
|
||||||
# 自动生成密钥
|
|
||||||
case "$prompt" in
|
case "$prompt" in
|
||||||
*JWT*)
|
*JWT*|*jwt*)
|
||||||
value=$(generate_secret 64)
|
value=$(generate_secret 64)
|
||||||
# 输出到 stderr,避免被捕获到返回值中
|
info "$(bilingual "已自动生成 JWT 密钥(128字符)" "JWT secret auto-generated (128 chars)")" >&2
|
||||||
info "已自动生成 JWT 密钥(128字符)" >&2
|
|
||||||
;;
|
;;
|
||||||
*管理员*|*ADMIN*)
|
*管理员*|*ADMIN*|*admin*|*reset*|*Reset*)
|
||||||
value=$(generate_secret 32)
|
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)
|
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)
|
value=$(generate_secret 16)
|
||||||
info "已自动生成数据库密码(32字符)" >&2
|
info "$(bilingual "已自动生成数据库密码(32字符)" "Database password auto-generated (32 chars)")" >&2
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
value="$default"
|
value="$default"
|
||||||
@@ -108,126 +130,115 @@ read_input() {
|
|||||||
echo "$value"
|
echo "$value"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 检查 Docker 环境
|
# 检查 Docker 环境 / Check Docker environment
|
||||||
check_docker() {
|
check_docker() {
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
title " 步骤 1: 环境检查"
|
title " $(bilingual "步骤 1: 环境检查" "Step 1: Environment Check")"
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
# 检查 Docker
|
|
||||||
if ! command -v docker &> /dev/null; then
|
if ! command -v docker &> /dev/null; then
|
||||||
error "Docker 未安装"
|
error "$(bilingual "Docker 未安装" "Docker is not installed")"
|
||||||
echo ""
|
echo ""
|
||||||
info "请先安装 Docker:"
|
info "$(bilingual "请先安装 Docker:" "Please install Docker first:")"
|
||||||
info " macOS: brew install docker"
|
info " macOS: brew install docker"
|
||||||
info " Ubuntu/Debian: apt-get install docker.io"
|
info " Ubuntu/Debian: apt-get install docker.io"
|
||||||
info " CentOS/RHEL: yum install docker"
|
info " CentOS/RHEL: yum install docker"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
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
|
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
|
else
|
||||||
error "Docker Compose 未安装"
|
error "$(bilingual "Docker Compose 未安装" "Docker Compose is not installed")"
|
||||||
echo ""
|
echo ""
|
||||||
info "请先安装 Docker Compose:"
|
info "$(bilingual "请先安装 Docker Compose:" "Please install Docker Compose:")"
|
||||||
info " https://docs.docker.com/compose/install/"
|
info " https://docs.docker.com/compose/install/"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查 Docker 守护进程
|
|
||||||
if ! docker info &> /dev/null; then
|
if ! docker info &> /dev/null; then
|
||||||
error "Docker 守护进程未运行"
|
error "$(bilingual "Docker 守护进程未运行" "Docker daemon is not running")"
|
||||||
info "请启动 Docker 服务:"
|
info "$(bilingual "请启动 Docker 服务:" "Please start Docker:")"
|
||||||
info " macOS: 打开 Docker Desktop"
|
info " $(bilingual "macOS: 打开 Docker Desktop" "macOS: Open Docker Desktop")"
|
||||||
info " Linux: systemctl start docker"
|
info " Linux: systemctl start docker"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
info "Docker 守护进程运行正常"
|
info "$(bilingual "Docker 守护进程运行正常" "Docker daemon is running")"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# 交互式配置收集
|
# 交互式配置收集 / Interactive configuration
|
||||||
collect_configuration() {
|
collect_configuration() {
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
title " 步骤 2: 配置收集"
|
title " $(bilingual "步骤 2: 配置收集" "Step 2: Configuration")"
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo ""
|
echo ""
|
||||||
info "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成"
|
info "$(bilingual "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成" "💡 All options are optional, press Enter for default or auto-generated values")"
|
||||||
echo ""
|
echo ""
|
||||||
warn "密钥配置:回车将自动生成安全的随机密钥"
|
warn "$(bilingual "密钥配置:回车将自动生成安全的随机密钥" "Secrets: Enter to auto-generate secure random keys")"
|
||||||
warn "其他配置:回车将使用括号中的默认值"
|
warn "$(bilingual "其他配置:回车将使用括号中的默认值" "Other: Enter to use default value in brackets")"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 基础配置
|
title "$(bilingual "【基础配置】" "【Basic】")"
|
||||||
title "【基础配置】"
|
echo -e "${CYAN}$(bilingual "将配置:服务器端口、MySQL端口、时区" "Server port, MySQL port, Timezone")${NC}"
|
||||||
echo -e "${CYAN}将配置:服务器端口、MySQL端口、时区${NC}"
|
|
||||||
# 生成随机端口作为默认值
|
|
||||||
DEFAULT_PORT=$(generate_random_port)
|
DEFAULT_PORT=$(generate_random_port)
|
||||||
SERVER_PORT=$(read_input "➤ 服务器端口" "$DEFAULT_PORT")
|
SERVER_PORT=$(read_input "$(bilingual "➤ 服务器端口" "➤ Server port")" "$DEFAULT_PORT")
|
||||||
MYSQL_PORT=$(read_input "➤ MySQL 端口(外部访问)" "3307")
|
MYSQL_PORT=$(read_input "$(bilingual "➤ MySQL 端口(外部访问)" "➤ MySQL port (external)")" "3307")
|
||||||
TZ=$(read_input "➤ 时区" "Asia/Shanghai")
|
TZ=$(read_input "$(bilingual "➤ 时区" "➤ Timezone")" "Asia/Shanghai")
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 数据库配置
|
title "$(bilingual "【数据库配置】" "【Database】")"
|
||||||
title "【数据库配置】"
|
echo -e "${CYAN}$(bilingual "将配置:数据库用户名、数据库密码" "Database username, password")${NC}"
|
||||||
echo -e "${CYAN}将配置:数据库用户名、数据库密码${NC}"
|
echo -e "${YELLOW}$(bilingual "💡 提示:密码留空将自动生成 32 字符的安全随机密码" "💡 Leave password empty to auto-generate 32-char password")${NC}"
|
||||||
echo -e "${YELLOW}💡 提示:密码留空将自动生成 32 字符的安全随机密码${NC}"
|
DB_USERNAME=$(read_input "$(bilingual "➤ 数据库用户名" "➤ Database username")" "root")
|
||||||
DB_USERNAME=$(read_input "➤ 数据库用户名" "root")
|
DB_PASSWORD=$(read_input "$(bilingual "➤ 数据库密码" "➤ Database password")" "" "secret")
|
||||||
DB_PASSWORD=$(read_input "➤ 数据库密码" "" "secret")
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 安全配置
|
title "$(bilingual "【安全配置】" "【Security】")"
|
||||||
title "【安全配置】"
|
echo -e "${CYAN}$(bilingual "将配置:JWT密钥、管理员密码重置密钥、数据加密密钥" "JWT secret, Admin reset key, Encryption key")${NC}"
|
||||||
echo -e "${CYAN}将配置:JWT密钥、管理员密码重置密钥、数据加密密钥${NC}"
|
echo -e "${YELLOW}$(bilingual "💡 提示:留空将自动生成高强度随机密钥(推荐)" "💡 Leave empty to auto-generate strong keys (recommended)")${NC}"
|
||||||
echo -e "${YELLOW}💡 提示:留空将自动生成高强度随机密钥(推荐)${NC}"
|
JWT_SECRET=$(read_input "$(bilingual "➤ JWT 密钥" "➤ JWT secret")" "" "secret")
|
||||||
JWT_SECRET=$(read_input "➤ JWT 密钥" "" "secret")
|
ADMIN_RESET_PASSWORD_KEY=$(read_input "$(bilingual "➤ 管理员密码重置密钥" "➤ Admin password reset key")" "" "secret")
|
||||||
ADMIN_RESET_PASSWORD_KEY=$(read_input "➤ 管理员密码重置密钥" "" "secret")
|
CRYPTO_SECRET_KEY=$(read_input "$(bilingual "➤ 加密密钥(用于加密 API Key)" "➤ Encryption key (for API Key)")" "" "secret")
|
||||||
CRYPTO_SECRET_KEY=$(read_input "➤ 加密密钥(用于加密 API Key)" "" "secret")
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 日志配置
|
title "$(bilingual "【日志配置】" "【Logging】")"
|
||||||
title "【日志配置】"
|
echo -e "${CYAN}$(bilingual "将配置:Root日志级别、应用日志级别" "Root log level, App log level")${NC}"
|
||||||
echo -e "${CYAN}将配置:Root日志级别、应用日志级别${NC}"
|
echo -e "${YELLOW}$(bilingual "可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF" "Levels: TRACE, DEBUG, INFO, WARN, ERROR, OFF")${NC}"
|
||||||
echo -e "${YELLOW}可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF${NC}"
|
LOG_LEVEL_ROOT=$(read_input "$(bilingual "➤ Root 日志级别(第三方库)" "➤ Root log level (3rd party)")" "WARN")
|
||||||
LOG_LEVEL_ROOT=$(read_input "➤ Root 日志级别(第三方库)" "WARN")
|
LOG_LEVEL_APP=$(read_input "$(bilingual "➤ 应用日志级别" "➤ App log level")" "INFO")
|
||||||
LOG_LEVEL_APP=$(read_input "➤ 应用日志级别" "INFO")
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 自动设置不需要用户输入的配置
|
|
||||||
SPRING_PROFILES_ACTIVE="prod"
|
SPRING_PROFILES_ACTIVE="prod"
|
||||||
ALLOW_PRERELEASE="false"
|
ALLOW_PRERELEASE="false"
|
||||||
GITHUB_REPO="WrBug/PolyHermes"
|
GITHUB_REPO="WrBug/PolyHermes"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 下载 docker-compose.prod.yml(如果不存在)
|
# 下载 docker-compose.prod.yml(如果不存在)/ Download docker-compose.prod.yml if missing
|
||||||
download_docker_compose_file() {
|
download_docker_compose_file() {
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
title " 步骤 3: 获取部署配置"
|
title " $(bilingual "步骤 3: 获取部署配置" "Step 3: Get Deploy Config")"
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
if [ -f "docker-compose.prod.yml" ]; then
|
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 ""
|
echo ""
|
||||||
return 0
|
return 0
|
||||||
fi
|
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"
|
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
|
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
|
else
|
||||||
error "docker-compose.prod.yml 下载失败"
|
error "$(bilingual "docker-compose.prod.yml 下载失败" "Failed to download docker-compose.prod.yml")"
|
||||||
warn "请检查网络连接或手动下载:"
|
warn "$(bilingual "请检查网络连接或手动下载:" "Check network or download manually:")"
|
||||||
warn " $compose_url"
|
warn " $compose_url"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -235,28 +246,26 @@ download_docker_compose_file() {
|
|||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# 生成 .env 文件
|
# 生成 .env 文件 / Generate .env file
|
||||||
generate_env_file() {
|
generate_env_file() {
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
title " 步骤 4: 生成环境变量文件"
|
title " $(bilingual "步骤 4: 生成环境变量文件" "Step 4: Generate .env")"
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
# 备份现有 .env 文件
|
|
||||||
if [ -f ".env" ]; then
|
if [ -f ".env" ]; then
|
||||||
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
|
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
|
||||||
cp .env "$BACKUP_FILE"
|
cp .env "$BACKUP_FILE"
|
||||||
warn "已备份现有配置文件到: $BACKUP_FILE"
|
warn "$(bilingual "已备份现有配置文件到" "Backed up existing config to"): $BACKUP_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 生成新的 .env 文件
|
|
||||||
cat > .env <<EOF
|
cat > .env <<EOF
|
||||||
# ========================================
|
# ========================================
|
||||||
# PolyHermes 生产环境配置
|
# PolyHermes Production Config / 生产环境配置
|
||||||
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
|
# Generated / 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 基础配置
|
# Basic / 基础配置
|
||||||
# ============================================
|
# ============================================
|
||||||
TZ=${TZ}
|
TZ=${TZ}
|
||||||
SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE}
|
SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE}
|
||||||
@@ -264,112 +273,107 @@ SERVER_PORT=${SERVER_PORT}
|
|||||||
MYSQL_PORT=${MYSQL_PORT}
|
MYSQL_PORT=${MYSQL_PORT}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 数据库配置
|
# Database / 数据库配置
|
||||||
# ============================================
|
# ============================================
|
||||||
DB_URL=jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
|
DB_URL=jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
|
||||||
DB_USERNAME=${DB_USERNAME}
|
DB_USERNAME=${DB_USERNAME}
|
||||||
DB_PASSWORD=${DB_PASSWORD}
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 安全配置(请妥善保管)
|
# Security (keep safe) / 安全配置(请妥善保管)
|
||||||
# ============================================
|
# ============================================
|
||||||
JWT_SECRET=${JWT_SECRET}
|
JWT_SECRET=${JWT_SECRET}
|
||||||
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY}
|
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY}
|
||||||
CRYPTO_SECRET_KEY=${CRYPTO_SECRET_KEY}
|
CRYPTO_SECRET_KEY=${CRYPTO_SECRET_KEY}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 日志配置
|
# Logging / 日志配置
|
||||||
# ============================================
|
# ============================================
|
||||||
LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT}
|
LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT}
|
||||||
LOG_LEVEL_APP=${LOG_LEVEL_APP}
|
LOG_LEVEL_APP=${LOG_LEVEL_APP}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 其他配置
|
# Other / 其他配置
|
||||||
# ============================================
|
# ============================================
|
||||||
ALLOW_PRERELEASE=${ALLOW_PRERELEASE}
|
ALLOW_PRERELEASE=${ALLOW_PRERELEASE}
|
||||||
GITHUB_REPO=${GITHUB_REPO}
|
GITHUB_REPO=${GITHUB_REPO}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
info "配置文件已生成: .env"
|
info "$(bilingual "配置文件已生成" "Config file generated"): .env"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 显示配置摘要
|
title "$(bilingual "【配置摘要】" "【Config Summary】")"
|
||||||
title "【配置摘要】"
|
echo " $(bilingual "服务器端口" "Server port"): ${SERVER_PORT}"
|
||||||
echo " 服务器端口: ${SERVER_PORT}"
|
echo " $(bilingual "MySQL 端口" "MySQL port"): ${MYSQL_PORT}"
|
||||||
echo " MySQL 端口: ${MYSQL_PORT}"
|
echo " $(bilingual "时区" "Timezone"): ${TZ}"
|
||||||
echo " 时区: ${TZ}"
|
echo " $(bilingual "数据库用户" "DB user"): ${DB_USERNAME}"
|
||||||
echo " 数据库用户: ${DB_USERNAME}"
|
echo " $(bilingual "数据库密码" "DB password"): ${DB_PASSWORD:0:8}... $(bilingual "(已隐藏)" "(hidden)")"
|
||||||
echo " 数据库密码: ${DB_PASSWORD:0:8}... (已隐藏)"
|
echo " $(bilingual "JWT 密钥" "JWT secret"): ${JWT_SECRET:0:16}... $(bilingual "(已隐藏)" "(hidden)")"
|
||||||
echo " JWT 密钥: ${JWT_SECRET:0:16}... (已隐藏)"
|
echo " $(bilingual "管理员重置密钥" "Admin reset key"): ${ADMIN_RESET_PASSWORD_KEY:0:16}... $(bilingual "(已隐藏)" "(hidden)")"
|
||||||
echo " 管理员重置密钥: ${ADMIN_RESET_PASSWORD_KEY:0:16}... (已隐藏)"
|
echo " $(bilingual "加密密钥" "Encryption key"): ${CRYPTO_SECRET_KEY:0:16}... $(bilingual "(已隐藏)" "(hidden)")"
|
||||||
echo " 加密密钥: ${CRYPTO_SECRET_KEY:0:16}... (已隐藏)"
|
echo " $(bilingual "日志级别" "Log level"): Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}"
|
||||||
echo " 日志级别: Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}"
|
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# 拉取镜像
|
# 拉取镜像 / Pull images
|
||||||
pull_images() {
|
pull_images() {
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
title " 步骤 5: 拉取 Docker 镜像"
|
title " $(bilingual "步骤 5: 拉取 Docker 镜像" "Step 5: Pull Docker Images")"
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
info "正在从 Docker Hub 拉取最新镜像..."
|
info "$(bilingual "正在从 Docker Hub 拉取最新镜像..." "Pulling latest images from Docker Hub...")"
|
||||||
|
|
||||||
# 拉取应用镜像
|
|
||||||
if docker pull wrbug/polyhermes:latest; then
|
if docker pull wrbug/polyhermes:latest; then
|
||||||
info "应用镜像拉取成功: wrbug/polyhermes:latest"
|
info "$(bilingual "应用镜像拉取成功" "App image pulled"): wrbug/polyhermes:latest"
|
||||||
else
|
else
|
||||||
error "应用镜像拉取失败"
|
error "$(bilingual "应用镜像拉取失败" "Failed to pull app image")"
|
||||||
warn "可能的原因:"
|
warn "$(bilingual "可能的原因:" "Possible reasons:")"
|
||||||
warn " 1. 网络连接问题"
|
warn " 1. $(bilingual "网络连接问题" "Network issue")"
|
||||||
warn " 2. Docker Hub 服务异常"
|
warn " 2. $(bilingual "Docker Hub 服务异常" "Docker Hub unavailable")"
|
||||||
warn " 3. 镜像不存在"
|
warn " 3. $(bilingual "镜像不存在" "Image not found")"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 拉取 MySQL 镜像
|
|
||||||
if docker pull mysql:8.2; then
|
if docker pull mysql:8.2; then
|
||||||
info "MySQL 镜像拉取成功: mysql:8.2"
|
info "$(bilingual "MySQL 镜像拉取成功" "MySQL image pulled"): mysql:8.2"
|
||||||
else
|
else
|
||||||
warn "MySQL 镜像拉取失败,将在启动时自动下载"
|
warn "$(bilingual "MySQL 镜像拉取失败,将在启动时自动下载" "MySQL pull failed, will download on start")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# 部署服务
|
# 部署服务 / Deploy services
|
||||||
deploy_services() {
|
deploy_services() {
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
title " 步骤 6: 部署服务"
|
title " $(bilingual "步骤 6: 部署服务" "Step 6: Deploy Services")"
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
# 停止现有服务
|
|
||||||
if docker compose -f docker-compose.prod.yml ps -q 2>/dev/null | grep -q .; then
|
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
|
docker compose -f docker-compose.prod.yml down
|
||||||
info "已停止现有服务"
|
info "$(bilingual "已停止现有服务" "Stopped existing services")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 启动服务
|
info "$(bilingual "正在启动服务..." "Starting services...")"
|
||||||
info "正在启动服务..."
|
|
||||||
if docker compose -f docker-compose.prod.yml up -d; then
|
if docker compose -f docker-compose.prod.yml up -d; then
|
||||||
info "服务启动成功"
|
info "$(bilingual "服务启动成功" "Services started")"
|
||||||
else
|
else
|
||||||
error "服务启动失败"
|
error "$(bilingual "服务启动失败" "Failed to start services")"
|
||||||
error "请检查日志: docker compose -f docker-compose.prod.yml logs"
|
error "$(bilingual "请检查日志" "Check logs"): docker compose -f docker-compose.prod.yml logs"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# 健康检查
|
# 健康检查 / Health check
|
||||||
health_check() {
|
health_check() {
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
title " 步骤 7: 健康检查"
|
title " $(bilingual "步骤 7: 健康检查" "Step 7: Health Check")"
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
info "等待服务启动(最多等待 60 秒)..."
|
info "$(bilingual "等待服务启动(最多等待 60 秒)..." "Waiting for services (up to 60s)...")"
|
||||||
|
|
||||||
local max_attempts=12
|
local max_attempts=12
|
||||||
local attempt=0
|
local attempt=0
|
||||||
@@ -377,13 +381,11 @@ health_check() {
|
|||||||
while [ $attempt -lt $max_attempts ]; do
|
while [ $attempt -lt $max_attempts ]; do
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
|
|
||||||
# 检查容器状态
|
|
||||||
if docker compose -f docker-compose.prod.yml ps | grep -q "Up"; then
|
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
|
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 ""
|
echo ""
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -394,79 +396,76 @@ health_check() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
warn "健康检查超时,请手动检查服务状态"
|
warn "$(bilingual "健康检查超时,请手动检查服务状态" "Health check timeout, please check services manually")"
|
||||||
warn "查看日志: docker compose -f docker-compose.prod.yml logs -f"
|
warn "$(bilingual "查看日志" "View logs"): docker compose -f docker-compose.prod.yml logs -f"
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# 显示部署信息
|
# 显示部署信息 / Show deployment info
|
||||||
show_deployment_info() {
|
show_deployment_info() {
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
title " 部署完成!"
|
title " $(bilingual "部署完成!" "Deployment Complete!")"
|
||||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
info "访问地址: ${GREEN}http://localhost:${SERVER_PORT}${NC}"
|
info "$(bilingual "访问地址" "Access URL"): ${GREEN}http://localhost:${SERVER_PORT}${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
title "【常用命令】"
|
title "$(bilingual "【常用命令】" "【Common Commands】")"
|
||||||
echo -e " 查看服务状态: ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}"
|
echo -e " $(bilingual "查看服务状态" "Status"): ${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 " $(bilingual "查看日志" "Logs"): ${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 " $(bilingual "停止服务" "Stop"): ${CYAN}docker compose -f docker-compose.prod.yml down${NC}"
|
||||||
echo -e " 重启服务: ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}"
|
echo -e " $(bilingual "重启服务" "Restart"): ${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}"
|
echo -e " $(bilingual "更新镜像" "Update"): ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
title "【数据库连接信息】"
|
title "$(bilingual "【数据库连接信息】" "【Database Connection】")"
|
||||||
echo -e " 主机: ${CYAN}localhost${NC}"
|
echo -e " $(bilingual "主机" "Host"): ${CYAN}localhost${NC}"
|
||||||
echo -e " 端口: ${CYAN}${MYSQL_PORT}${NC}"
|
echo -e " $(bilingual "端口" "Port"): ${CYAN}${MYSQL_PORT}${NC}"
|
||||||
echo -e " 数据库: ${CYAN}polyhermes${NC}"
|
echo -e " $(bilingual "数据库" "Database"): ${CYAN}polyhermes${NC}"
|
||||||
echo -e " 用户名: ${CYAN}${DB_USERNAME}${NC}"
|
echo -e " $(bilingual "用户名" "Username"): ${CYAN}${DB_USERNAME}${NC}"
|
||||||
echo -e " 密码: ${CYAN}${DB_PASSWORD}${NC}"
|
echo -e " $(bilingual "密码" "Password"): ${CYAN}${DB_PASSWORD}${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
title "【管理员重置密钥】"
|
title "$(bilingual "【管理员重置密钥】" "【Admin Reset Key】")"
|
||||||
echo -e " 重置密钥: ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}"
|
echo -e " $(bilingual "重置密钥" "Reset key"): ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}"
|
||||||
echo -e " ${YELLOW}💡 此密钥用于重置管理员密码,请妥善保管${NC}"
|
echo -e " ${YELLOW}$(bilingual "💡 此密钥用于重置管理员密码,请妥善保管" "💡 Keep this key safe; it is used to reset admin password")${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
warn "重要提示:"
|
warn "$(bilingual "重要提示:" "Important:")"
|
||||||
warn " 1. 请妥善保管 .env 文件,勿提交到版本控制系统"
|
warn " 1. $(bilingual "请妥善保管 .env 文件,勿提交到版本控制系统" "Keep .env secure; do not commit to version control")"
|
||||||
warn " 2. 定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data)"
|
warn " 2. $(bilingual "定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data)" "Back up DB regularly (Docker volume: polyhermes_mysql-data)")"
|
||||||
warn " 3. 生产环境建议配置反向代理(如 Nginx)并启用 HTTPS"
|
warn " 3. $(bilingual "生产环境建议配置反向代理(如 Nginx)并启用 HTTPS" "Use a reverse proxy (e.g. Nginx) and HTTPS in production")"
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# 主函数
|
# 主函数 / Main
|
||||||
main() {
|
main() {
|
||||||
clear
|
clear
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
title "========================================="
|
title "========================================="
|
||||||
title " PolyHermes 交互式一键部署脚本 "
|
title " $(bilingual "PolyHermes 交互式一键部署脚本" "PolyHermes Interactive Deploy") "
|
||||||
title "========================================="
|
title "========================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 执行部署流程
|
|
||||||
check_docker
|
check_docker
|
||||||
|
|
||||||
# 检查是否已存在 .env 文件
|
|
||||||
if [ -f ".env" ]; then
|
if [ -f ".env" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
title "【检测到现有配置】"
|
title "$(bilingual "【检测到现有配置】" "【Existing Config Found】")"
|
||||||
info "发现已存在的 .env 配置文件"
|
info "$(bilingual "发现已存在的 .env 配置文件" "Found existing .env file")"
|
||||||
echo ""
|
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
|
read -r use_existing
|
||||||
use_existing=${use_existing:-Y}
|
use_existing=${use_existing:-Y}
|
||||||
|
|
||||||
if [[ "$use_existing" =~ ^[Yy]$ ]]; then
|
if [[ "$use_existing" =~ ^[Yy]$ ]]; then
|
||||||
info "将使用现有配置,跳过配置步骤"
|
info "$(bilingual "将使用现有配置,跳过配置步骤" "Using existing config, skipping configuration")"
|
||||||
echo ""
|
echo ""
|
||||||
# 从现有 .env 文件读取必要的变量
|
|
||||||
source .env 2>/dev/null || true
|
source .env 2>/dev/null || true
|
||||||
else
|
else
|
||||||
warn "将重新配置,现有配置将被备份"
|
warn "$(bilingual "将重新配置,现有配置将被备份" "Will reconfigure; existing config will be backed up")"
|
||||||
echo ""
|
echo ""
|
||||||
collect_configuration
|
collect_configuration
|
||||||
fi
|
fi
|
||||||
@@ -476,21 +475,18 @@ main() {
|
|||||||
|
|
||||||
download_docker_compose_file
|
download_docker_compose_file
|
||||||
|
|
||||||
# 只有在重新配置时才生成新的 .env 文件
|
|
||||||
if [[ ! "$use_existing" =~ ^[Yy]$ ]] || [ ! -f ".env" ]; then
|
if [[ ! "$use_existing" =~ ^[Yy]$ ]] || [ ! -f ".env" ]; then
|
||||||
generate_env_file
|
generate_env_file
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 确认部署
|
|
||||||
echo ""
|
echo ""
|
||||||
title "【确认部署】"
|
title "$(bilingual "【确认部署】" "【Confirm Deploy】")"
|
||||||
echo -ne "${YELLOW}是否开始部署?[Y/n](回车默认为是): ${NC}"
|
echo -ne "${YELLOW}$(bilingual "是否开始部署?[Y/n](回车默认为是)" "Start deployment? [Y/n] (Enter = Yes)"): ${NC}"
|
||||||
read -r confirm
|
read -r confirm
|
||||||
|
|
||||||
# 默认为 Y,只有明确输入 n/N 才取消
|
|
||||||
confirm=${confirm:-Y}
|
confirm=${confirm:-Y}
|
||||||
if [[ "$confirm" =~ ^[Nn]$ ]]; then
|
if [[ "$confirm" =~ ^[Nn]$ ]]; then
|
||||||
warn "部署已取消"
|
warn "$(bilingual "部署已取消" "Deployment cancelled")"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -500,11 +496,11 @@ main() {
|
|||||||
health_check
|
health_check
|
||||||
show_deployment_info
|
show_deployment_info
|
||||||
|
|
||||||
info "部署流程已完成!"
|
info "$(bilingual "部署流程已完成!" "Deployment finished!")"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 捕获 Ctrl+C
|
# 捕获 Ctrl+C / Handle Ctrl+C
|
||||||
trap 'echo ""; warn "部署已中断"; exit 1' INT
|
trap 'echo ""; warn "$(bilingual "部署已中断" "Deployment interrupted")"; exit 1' INT
|
||||||
|
|
||||||
# 运行主函数
|
# 运行主函数 / Run main
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -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** | 自动价差模式下动态系数计算说明 |
|
||||||
+1
-1
@@ -55,7 +55,7 @@ effectiveMinSpread = baseSpread × coefficient
|
|||||||
|
|
||||||
- 需要策略的 `windowStartSeconds`、`windowEndSeconds` 传入计算处;若窗口长度为 0,可退化为系数 = 1.0 或 0.5(需约定)。
|
- 需要策略的 `windowStartSeconds`、`windowEndSeconds` 传入计算处;若窗口长度为 0,可退化为系数 = 1.0 或 0.5(需约定)。
|
||||||
|
|
||||||
**优点**:与「尾盘只在窗口内触发」一致,时间语义清晰;毫秒级 progress 更精确。
|
**优点**:与「加密价差策略只在窗口内触发」一致,时间语义清晰;毫秒级 progress 更精确。
|
||||||
**缺点**:`getAutoMinSpread` 需要增加当前时间(毫秒)和窗口参数(或传整个 strategy)。
|
**缺点**:`getAutoMinSpread` 需要增加当前时间(毫秒)和窗口参数(或传整个 strategy)。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -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!** 🚀
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# 加密市场尾盘策略 - 流程图
|
# 加密价差策略 - 流程图
|
||||||
|
|
||||||
## 一、整体架构
|
## 一、整体架构
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# 加密市场尾盘策略 - 5/15 分钟市场数据获取说明
|
# 加密价差策略 - 5/15 分钟市场数据获取说明
|
||||||
|
|
||||||
> 前端 UI 与交互详见 `crypto-tail-strategy-ui-spec.md`。
|
> 前端 UI 与交互详见 `crypto-tail-strategy-ui-spec.md`。
|
||||||
|
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
# 尾盘策略 - 最小价差参数流程分析
|
# 加密价差策略 - 最小价差参数流程分析
|
||||||
|
|
||||||
## 一、需求摘要
|
## 一、需求摘要
|
||||||
|
|
||||||
在现有尾盘策略上增加**最小价差**参数:当策略条件(时间窗、价格区间)满足时,再判断**当前周期 Binance K 线的开盘价与收盘价价差**是否满足最小价差;满足才下单,不满足则等待,直到价差满足再下单。
|
在现有加密价差策略上增加**最小价差**参数:当策略条件(时间窗、价格区间)满足时,再判断**当前周期 Binance K 线的开盘价与收盘价价差**是否满足最小价差;满足才下单,不满足则等待,直到价差满足再下单。
|
||||||
|
|
||||||
- **后端**:需订阅币安对应币对(如 BTC/USDC)的 K 线,维护当前周期的**开盘价**与**实时收盘价**,并在触发时做价差校验。
|
- **后端**:需订阅币安对应币对(如 BTC/USDC)的 K 线,维护当前周期的**开盘价**与**实时收盘价**,并在触发时做价差校验。
|
||||||
- **前端**:可配置三种场景——无、固定、自动(见下)。
|
- **前端**:可配置三种场景——无、固定、自动(见下)。
|
||||||
@@ -244,4 +244,4 @@ sequenceDiagram
|
|||||||
3. **下单与去重**
|
3. **下单与去重**
|
||||||
- 仍保持「每周期最多触发一次」;价差不满足时不写触发记录,直到某次同时满足价格与价差后才下单并写记录。
|
- 仍保持「每周期最多触发一次」;价差不满足时不写触发记录,直到某次同时满足价格与价差后才下单并写记录。
|
||||||
|
|
||||||
按上述流程即可在现有尾盘策略上接入「最小价差」参数,并由后端订阅币安 K 线、在触发前做价差校验;固定与自动的时序差异见**第六节时序图**。
|
按上述流程即可在现有加密价差策略上接入「最小价差」参数,并由后端订阅币安 K 线、在触发前做价差校验;固定与自动的时序差异见**第六节时序图**。
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
# 加密市场尾盘策略 - 任务梳理
|
# 加密价差策略 - 任务梳理
|
||||||
|
|
||||||
> 需求与 UI 见 `crypto-tail-strategy-ui-spec.md`,市场数据与执行规则见 `crypto-tail-strategy-market-data.md`。
|
> 需求与 UI 见 `crypto-tail-strategy-ui-spec.md`,市场数据与执行规则见 `crypto-tail-strategy-market-data.md`。
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
|
|
||||||
| 序号 | 任务 | 说明 |
|
| 序号 | 任务 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| B16 | 自动赎回包含尾盘策略仓位 | 尾盘策略产生的仓位与跟单/手动一视同仁,纳入现有自动赎回逻辑,不排除(见 UI 规格附录 A)。 |
|
| B16 | 自动赎回包含加密价差策略仓位 | 加密价差策略产生的仓位与跟单/手动一视同仁,纳入现有自动赎回逻辑,不排除(见 UI 规格附录 A)。 |
|
||||||
| B17 | 调度/定时或常驻 | 对已启用策略按周期(如每 10–30 秒)检查:当前周期、是否在时间窗口内、是否已触发、价格是否进区间;满足则执行下单并写触发记录。 |
|
| B17 | 调度/定时或常驻 | 对已启用策略按周期(如每 10–30 秒)检查:当前周期、是否在时间窗口内、是否已触发、价格是否进区间;满足则执行下单并写触发记录。 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
| 序号 | 任务 | 说明 |
|
| 序号 | 任务 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| F1 | 路由 | App.tsx 增加 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id`。 |
|
| F1 | 路由 | App.tsx 增加 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id`。 |
|
||||||
| F2 | 菜单 | Layout 中增加「尾盘策略」菜单项,与跟单同级或在其下;key 与路由一致。 |
|
| F2 | 菜单 | Layout 中增加「加密价差策略」菜单项,与跟单同级或在其下;key 与路由一致。 |
|
||||||
|
|
||||||
### 4.2 列表页
|
### 4.2 列表页
|
||||||
|
|
||||||
@@ -146,5 +146,5 @@ F10 触发记录
|
|||||||
- **时间区间**:仅当周期内当前时间落在 [windowStartSeconds, windowEndSeconds] 时才判断价格并下单;前端区间开始 ≤ 结束,且不超出 5min/15min。
|
- **时间区间**:仅当周期内当前时间落在 [windowStartSeconds, windowEndSeconds] 时才判断价格并下单;前端区间开始 ≤ 结束,且不超出 5min/15min。
|
||||||
- **每周期一次**:同一策略同一周期只触发一次(先满足价格的 outcome 买入,反方向不买)。
|
- **每周期一次**:同一策略同一周期只触发一次(先满足价格的 outcome 买入,反方向不买)。
|
||||||
- **重试**:下单失败最多重试 2 次,共 3 次;仍失败记入触发记录为失败。
|
- **重试**:下单失败最多重试 2 次,共 3 次;仍失败记入触发记录为失败。
|
||||||
- **自动赎回**:尾盘策略产生的仓位可被自动赎回,无排除逻辑。
|
- **自动赎回**:加密价差策略产生的仓位可被自动赎回,无排除逻辑。
|
||||||
- **创建前检查**:未配置自动赎回时点击新增策略弹出「去配置」弹窗,不打开表单。
|
- **创建前检查**:未配置自动赎回时点击新增策略弹出「去配置」弹窗,不打开表单。
|
||||||
+10
-10
@@ -1,4 +1,4 @@
|
|||||||
# 加密市场尾盘策略 - 前端 UI 规格
|
# 加密价差策略 - 前端 UI 规格
|
||||||
|
|
||||||
> 周期推导与市场数据获取详见 `crypto-tail-strategy-market-data.md`。
|
> 周期推导与市场数据获取详见 `crypto-tail-strategy-market-data.md`。
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
| 项目 | 说明 |
|
| 项目 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **菜单** | 在「跟单管理」同级或其下增加一项,如「尾盘策略」,key 建议 `/crypto-tail-strategy`。 |
|
| **菜单** | 在「跟单管理」同级或其下增加一项,如「加密价差策略」,key 建议 `/crypto-tail-strategy`。 |
|
||||||
| **路由** | 列表页 `/crypto-tail-strategy`;可选详情/触发记录 `/crypto-tail-strategy/records/:id`。 |
|
| **路由** | 列表页 `/crypto-tail-strategy`;可选详情/触发记录 `/crypto-tail-strategy/records/:id`。 |
|
||||||
|
|
||||||
参考:`Layout.tsx` 中 `/copy-trading`、`/backtest` 的配置;`App.tsx` 中对应 `Route`。
|
参考:`Layout.tsx` 中 `/copy-trading`、`/backtest` 的配置;`App.tsx` 中对应 `Route`。
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
| 元素 | 类型 | 说明 |
|
| 元素 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 页面标题 | 标题文案 | 如「加密尾盘策略」,用 `t('cryptoTailStrategy.list.title')`。 |
|
| 页面标题 | 标题文案 | 如「加密价差策略」,用 `t('cryptoTailStrategy.list.title')`。 |
|
||||||
| **钱包使用提示** | **Alert(Warning)** | **必须**在页面顶部或标题下方展示:提示用户**使用单独/专用钱包**运行本策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,进而造成策略执行异常(如余额不足、下单失败等)。文案走多语言 `t('cryptoTailStrategy.list.walletTip')`,可带 `showIcon`。 |
|
| **钱包使用提示** | **Alert(Warning)** | **必须**在页面顶部或标题下方展示:提示用户**使用单独/专用钱包**运行本策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,进而造成策略执行异常(如余额不足、下单失败等)。文案走多语言 `t('cryptoTailStrategy.list.walletTip')`,可带 `showIcon`。 |
|
||||||
| 新增策略 | Button(Primary) | 点击时**先检查自动赎回相关配置**(见 2.4);若未配置则弹出「去配置」简易弹窗,若已配置则打开「新增策略」表单弹窗。图标可用 `PlusOutlined`。 |
|
| 新增策略 | Button(Primary) | 点击时**先检查自动赎回相关配置**(见 2.4);若未配置则弹出「去配置」简易弹窗,若已配置则打开「新增策略」表单弹窗。图标可用 `PlusOutlined`。 |
|
||||||
| 筛选(可选) | Select / 筛选项 | 按账户、启用状态筛选;移动端可收起到抽屉或折叠。 |
|
| 筛选(可选) | Select / 筛选项 | 按账户、启用状态筛选;移动端可收起到抽屉或折叠。 |
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
1. **检查**:请求系统配置(如 `apiService.systemConfig.getConfig()` 或已有接口),判断是否已配置 Builder API Key(及可选:自动赎回已开启)。若 `builderApiKeyConfigured === false`(或后端约定之「未配置」状态),视为未配置。
|
1. **检查**:请求系统配置(如 `apiService.systemConfig.getConfig()` 或已有接口),判断是否已配置 Builder API Key(及可选:自动赎回已开启)。若 `builderApiKeyConfigured === false`(或后端约定之「未配置」状态),视为未配置。
|
||||||
2. **未配置时**:不打开新增策略表单,改为弹出**简易弹窗**(Modal),内容建议:
|
2. **未配置时**:不打开新增策略表单,改为弹出**简易弹窗**(Modal),内容建议:
|
||||||
- **标题**:如「请先配置自动赎回」,`t('cryptoTailStrategy.redeemRequiredModal.title')`。
|
- **标题**:如「请先配置自动赎回」,`t('cryptoTailStrategy.redeemRequiredModal.title')`。
|
||||||
- **正文**:简短说明尾盘策略依赖自动赎回,需要先在「系统设置」中配置 Builder API Key 及自动赎回。文案 `t('cryptoTailStrategy.redeemRequiredModal.description')`。
|
- **正文**:简短说明加密价差策略依赖自动赎回,需要先在「系统设置」中配置 Builder API Key 及自动赎回。文案 `t('cryptoTailStrategy.redeemRequiredModal.description')`。
|
||||||
- **操作**:
|
- **操作**:
|
||||||
- **去配置**:主按钮,点击后关闭弹窗并跳转到系统设置页(如 `/system-settings`,该页含 Relayer 配置与自动赎回开关)。
|
- **去配置**:主按钮,点击后关闭弹窗并跳转到系统设置页(如 `/system-settings`,该页含 Relayer 配置与自动赎回开关)。
|
||||||
- **取消**:次按钮或关闭图标,仅关闭弹窗。
|
- **取消**:次按钮或关闭图标,仅关闭弹窗。
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| **钱包提示** | 列表页与新增/编辑表单**必须**包含「使用单独钱包」的 Alert 提示,避免用户用混用钱包导致异常;文案走多语言。 |
|
| **钱包提示** | 列表页与新增/编辑表单**必须**包含「使用单独钱包」的 Alert 提示,避免用户用混用钱包导致异常;文案走多语言。 |
|
||||||
| **创建前检查** | 点击「新增策略」时先检查自动赎回/Builder API 是否已配置;未配置则弹出简易「去配置」弹窗,引导用户到系统设置配置 API Key 与自动赎回,不打开策略表单。 |
|
| **创建前检查** | 点击「新增策略」时先检查自动赎回/Builder API 是否已配置;未配置则弹出简易「去配置」弹窗,引导用户到系统设置配置 API Key 与自动赎回,不打开策略表单。 |
|
||||||
| 多语言 | 所有文案 `t('cryptoTailStrategy.xxx')`,在 `locales/zh-CN`、`zh-TW`、`en` 的 `common.json` 中增加键。需包含:`cryptoTailStrategy.list.walletTip`、`cryptoTailStrategy.form.walletTip`,以及 `cryptoTailStrategy.redeemRequiredModal.title`、`cryptoTailStrategy.redeemRequiredModal.description`、`cryptoTailStrategy.redeemRequiredModal.goToSettings`、`cryptoTailStrategy.redeemRequiredModal.cancel`。文案示例:列表页 `walletTip`:「请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。」表单内 `walletTip`:「建议使用专用钱包,避免手动操作等导致余额或下单异常。」未配置赎回弹窗 `title`:「请先配置自动赎回」;`description`:「尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。」;`goToSettings`:「去配置」;`cancel`:「取消」。 |
|
| 多语言 | 所有文案 `t('cryptoTailStrategy.xxx')`,在 `locales/zh-CN`、`zh-TW`、`en` 的 `common.json` 中增加键。需包含:`cryptoTailStrategy.list.walletTip`、`cryptoTailStrategy.form.walletTip`,以及 `cryptoTailStrategy.redeemRequiredModal.title`、`cryptoTailStrategy.redeemRequiredModal.description`、`cryptoTailStrategy.redeemRequiredModal.goToSettings`、`cryptoTailStrategy.redeemRequiredModal.cancel`。文案示例:列表页 `walletTip`:「请使用单独的钱包运行加密价差策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。」表单内 `walletTip`:「建议使用专用钱包,避免手动操作等导致余额或下单异常。」未配置赎回弹窗 `title`:「请先配置自动赎回」;`description`:「加密价差策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。」;`goToSettings`:「去配置」;`cancel`:「取消」。 |
|
||||||
| 金额 | 统一 `formatUSDC`(见 frontend.mdc)。 |
|
| 金额 | 统一 `formatUSDC`(见 frontend.mdc)。 |
|
||||||
| 响应式 | `useMediaQuery`;按钮触摸目标 ≥ 44px;移动端主操作突出。 |
|
| 响应式 | `useMediaQuery`;按钮触摸目标 ≥ 44px;移动端主操作突出。 |
|
||||||
| 类型 | 不用 `any`;为策略、触发记录定义 TypeScript 类型。 |
|
| 类型 | 不用 `any`;为策略、触发记录定义 TypeScript 类型。 |
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
| 新增/编辑弹窗 | `frontend/src/pages/CryptoTailStrategyList/FormModal.tsx` 或内嵌 Modal |
|
| 新增/编辑弹窗 | `frontend/src/pages/CryptoTailStrategyList/FormModal.tsx` 或内嵌 Modal |
|
||||||
| 触发记录 | `frontend/src/pages/CryptoTailStrategyList/TriggerRecordsModal.tsx` 或 `CryptoTailStrategyRecords.tsx` |
|
| 触发记录 | `frontend/src/pages/CryptoTailStrategyList/TriggerRecordsModal.tsx` 或 `CryptoTailStrategyRecords.tsx` |
|
||||||
| 路由 | `App.tsx` 中 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id` |
|
| 路由 | `App.tsx` 中 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id` |
|
||||||
| 菜单 | `Layout.tsx` 中增加「尾盘策略」菜单项 |
|
| 菜单 | `Layout.tsx` 中增加「加密价差策略」菜单项 |
|
||||||
| 类型 | `frontend/src/types/index.ts` 或 `types/cryptoTailStrategy.ts` 中增加策略与触发记录类型 |
|
| 类型 | `frontend/src/types/index.ts` 或 `types/cryptoTailStrategy.ts` 中增加策略与触发记录类型 |
|
||||||
| 多语言 | `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 中增加 `cryptoTailStrategy.*` |
|
| 多语言 | `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 中增加 `cryptoTailStrategy.*` |
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
|
|
||||||
## 7. 小结:UI 包含的主要元素
|
## 7. 小结:UI 包含的主要元素
|
||||||
|
|
||||||
- **导航**:主导航中「尾盘策略」入口。
|
- **导航**:主导航中「加密价差策略」入口。
|
||||||
- **列表页**:标题、钱包提示 Alert、新增按钮(点击前先检查赎回配置,未配置则弹「去配置」简易弹窗)、筛选、表格/卡片(策略名、市场、价格区间、投入方式、状态、最近触发、操作)、加载与空状态。
|
- **列表页**:标题、钱包提示 Alert、新增按钮(点击前先检查赎回配置,未配置则弹「去配置」简易弹窗)、筛选、表格/卡片(策略名、市场、价格区间、投入方式、状态、最近触发、操作)、加载与空状态。
|
||||||
- **未配置赎回弹窗**:简易 Modal,提示依赖自动赎回、需先配置 Builder API Key 与自动赎回;按钮「去配置」(跳转 `/system-settings`)、「取消」。
|
- **未配置赎回弹窗**:简易 Modal,提示依赖自动赎回、需先配置 Builder API Key 与自动赎回;按钮「去配置」(跳转 `/system-settings`)、「取消」。
|
||||||
- **表单弹窗**:策略名、账户、市场选择、minPrice/maxPrice、投入方式(比例/固定)、启用开关、提交/取消。
|
- **表单弹窗**:策略名、账户、市场选择、minPrice/maxPrice、投入方式(比例/固定)、启用开关、提交/取消。
|
||||||
@@ -169,9 +169,9 @@
|
|||||||
|
|
||||||
## 附录 A 后端/产品要求:自动赎回须支持本策略仓位
|
## 附录 A 后端/产品要求:自动赎回须支持本策略仓位
|
||||||
|
|
||||||
自动赎回逻辑**必须支持赎回由尾盘策略产生的订单所对应的仓位**。即:本策略触发的市价买入会形成仓位,这些仓位在满足「可赎回」条件时,应被纳入现有自动赎回流程并正常发起赎回,不得因来源为「尾盘策略」而被排除。后端实现时需保证:
|
自动赎回逻辑**必须支持赎回由加密价差策略产生的订单所对应的仓位**。即:本策略触发的市价买入会形成仓位,这些仓位在满足「可赎回」条件时,应被纳入现有自动赎回流程并正常发起赎回,不得因来源为「加密价差策略」而被排除。后端实现时需保证:
|
||||||
|
|
||||||
- 尾盘策略下单产生的仓位,与跟单/手动下单等来源的仓位一视同仁,参与可赎回查询与批量赎回;
|
- 加密价差策略下单产生的仓位,与跟单/手动下单等来源的仓位一视同仁,参与可赎回查询与批量赎回;
|
||||||
- 若当前自动赎回按账户或仓位类型过滤,需将「尾盘策略订单产生的仓位」包含在内。
|
- 若当前自动赎回按账户或仓位类型过滤,需将「加密价差策略订单产生的仓位」包含在内。
|
||||||
|
|
||||||
这样前端所依赖的「自动赎回」对该策略才完整有效。
|
这样前端所依赖的「自动赎回」对该策略才完整有效。
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
# 加密价差策略配置指南
|
||||||
|
|
||||||
|
## 一、什么是加密价差策略?
|
||||||
|
|
||||||
|
加密价差策略是一种自动化交易策略,专门用于 Polymarket 加密市场的 **5分钟** 或 **15分钟** "Up or Down" 市场。
|
||||||
|
|
||||||
|
**核心逻辑**:在指定时间窗口内,当市场价格进入您设定的价格区间时,系统会自动以固定价格(0.99)买入,无需手动操作。
|
||||||
|
|
||||||
|
**适用场景**:
|
||||||
|
- 您希望捕捉市场在周期末段的价格波动
|
||||||
|
- 您想自动化执行交易,避免手动盯盘
|
||||||
|
- 您对市场走势有一定判断,希望设置条件自动触发
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、策略工作原理
|
||||||
|
|
||||||
|
### 2.1 基本流程
|
||||||
|
|
||||||
|
```
|
||||||
|
周期开始 → 时间窗口内 → 价格进入区间 → 自动下单
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **周期**:每个市场按固定周期运行(5分钟或15分钟)
|
||||||
|
- 5分钟市场:每5分钟为一个周期(如 10:00、10:05、10:10...)
|
||||||
|
- 15分钟市场:每15分钟为一个周期(如 10:00、10:15、10:30...)
|
||||||
|
|
||||||
|
2. **时间窗口**:您可以在周期内设置一个时间段
|
||||||
|
- 例如:15分钟市场,设置窗口为「3分钟~12分钟」
|
||||||
|
- 表示:从周期开始后第3分钟到第12分钟之间才会触发
|
||||||
|
|
||||||
|
3. **价格区间**:设置触发价格范围
|
||||||
|
- 例如:最低价 0.50,最高价 0.80
|
||||||
|
- 表示:当市场价格在 0.50~0.80 之间时才会触发
|
||||||
|
|
||||||
|
4. **自动下单**:满足条件后,系统自动以 0.99 的价格买入
|
||||||
|
|
||||||
|
### 2.2 重要限制
|
||||||
|
|
||||||
|
- **每周期最多触发一次**:同一个周期内,即使多次满足条件,也只下单一次
|
||||||
|
- **固定下单价格**:所有订单都以 0.99 的价格提交
|
||||||
|
- **需要单独钱包**:建议使用专门的钱包运行加密价差策略,避免与其他操作(手动交易、跟单等)冲突
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、参数详细说明
|
||||||
|
|
||||||
|
### 3.1 基础参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 必填 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **账户** | 选择用于交易的钱包账户 | ✅ | 账户A |
|
||||||
|
| **策略名称** | 给策略起个名字,方便识别 | ❌ | "BTC 15分钟加密价差策略" |
|
||||||
|
| **市场** | 选择要交易的市场(5分钟或15分钟) | ✅ | btc-updown-15m |
|
||||||
|
|
||||||
|
### 3.2 周期设置
|
||||||
|
|
||||||
|
| 参数 | 说明 | 必填 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **周期长度** | 由选择的市场自动确定 | ✅ | 15分钟(900秒) |
|
||||||
|
| **时间窗口开始** | 从周期起点算起,多少分钟后开始监听 | ✅ | 3分0秒 |
|
||||||
|
| **时间窗口结束** | 从周期起点算起,多少分钟后停止监听 | ✅ | 12分0秒 |
|
||||||
|
|
||||||
|
**时间窗口说明**:
|
||||||
|
- 5分钟市场:可选 0~5 分钟内的任意时间段
|
||||||
|
- 15分钟市场:可选 0~15 分钟内的任意时间段
|
||||||
|
- **开始时间必须 ≤ 结束时间**
|
||||||
|
- 窗口外的时间即使价格满足也不会触发
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
- 15分钟市场,窗口「3分0秒 ~ 12分0秒」
|
||||||
|
- 周期开始后 0~3 分钟:不监听
|
||||||
|
- 周期开始后 3~12 分钟:监听价格,满足条件即触发
|
||||||
|
- 周期开始后 12~15 分钟:不监听
|
||||||
|
|
||||||
|
### 3.3 价格区间
|
||||||
|
|
||||||
|
| 参数 | 说明 | 必填 | 取值范围 | 示例 |
|
||||||
|
|------|------|------|----------|------|
|
||||||
|
| **最低价 (minPrice)** | 触发的最低价格 | ✅ | 0~1 | 0.50 |
|
||||||
|
| **最高价 (maxPrice)** | 触发的最高价格 | ❌ | 0~1,默认1 | 0.80 |
|
||||||
|
|
||||||
|
**价格区间说明**:
|
||||||
|
- 价格范围是 0~1 之间的小数
|
||||||
|
- 当市场价格在 [最低价, 最高价] 区间内时才会触发
|
||||||
|
- 如果不填最高价,默认使用 1.0(即只要价格 ≥ 最低价就触发)
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
- 最低价 0.50,最高价 0.80
|
||||||
|
- 价格 0.45:不触发(低于最低价)
|
||||||
|
- 价格 0.60:触发 ✅(在区间内)
|
||||||
|
- 价格 0.85:不触发(高于最高价)
|
||||||
|
|
||||||
|
### 3.4 投入金额
|
||||||
|
|
||||||
|
| 参数 | 说明 | 必填 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **投入方式** | 选择按比例或固定金额 | ✅ | 按比例 / 固定金额 |
|
||||||
|
| **比例 (%)** | 按账户余额的百分比投入 | 条件必填 | 10%(账户有100 USDC,投入10 USDC) |
|
||||||
|
| **固定金额 (USDC)** | 每次固定投入的金额 | 条件必填 | 50 USDC |
|
||||||
|
|
||||||
|
**投入方式说明**:
|
||||||
|
|
||||||
|
**方式一:按比例 (RATIO)**
|
||||||
|
- 每次触发时,按账户当前可用余额的百分比投入
|
||||||
|
- 例如:账户有 100 USDC,设置比例 10%
|
||||||
|
- 第1次触发:投入 10 USDC
|
||||||
|
- 第2次触发:如果余额变为 90 USDC,投入 9 USDC
|
||||||
|
- **优点**:自动适应账户余额变化
|
||||||
|
- **缺点**:每次投入金额可能不同
|
||||||
|
|
||||||
|
**方式二:固定金额 (FIXED)**
|
||||||
|
- 每次触发时,固定投入指定金额
|
||||||
|
- 例如:设置固定金额 50 USDC
|
||||||
|
- 每次触发都投入 50 USDC
|
||||||
|
- **优点**:投入金额稳定,便于管理
|
||||||
|
- **缺点**:需要确保账户余额充足
|
||||||
|
|
||||||
|
**注意事项**:
|
||||||
|
- 最小下单金额:至少 1 USDC
|
||||||
|
- 如果账户余额不足,下单会失败并记录失败原因
|
||||||
|
|
||||||
|
### 3.5 价差过滤(高级功能)
|
||||||
|
|
||||||
|
价差功能用于根据币安 BTC/USDC 的 K 线波动决定是否触发,支持「最小价差」与「最大价差」两种方向。
|
||||||
|
|
||||||
|
| 参数 | 说明 | 必填 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **价差模式** | 选择价差校验方式 | ✅ | 无 / 固定 / 自动 |
|
||||||
|
| **价差方向** | 最小价差(≥ 触发)或 最大价差(≤ 触发) | ✅ | 最小价差 / 最大价差 |
|
||||||
|
| **价差值** | 固定模式时填写(单位:USDC) | 条件必填 | 30 |
|
||||||
|
|
||||||
|
**价差方向说明**:
|
||||||
|
|
||||||
|
- **最小价差**:当币安 K 线价差 **≥** 设定值时才触发
|
||||||
|
- 适合:只在波动「足够大」时交易(避免波动过小、不值得进场)
|
||||||
|
- **最大价差**:当币安 K 线价差 **≤** 设定值时才触发
|
||||||
|
- 适合:只在波动「足够小」时交易(避免波动过大、风险高)
|
||||||
|
|
||||||
|
**三种价差模式**:
|
||||||
|
|
||||||
|
**模式一:无 (NONE)**
|
||||||
|
- 不进行价差校验
|
||||||
|
- 只要时间窗口和价格区间满足就触发
|
||||||
|
- **适合**:不关心币安价格波动,只看 Polymarket 价格
|
||||||
|
|
||||||
|
**模式二:固定 (FIXED)**
|
||||||
|
- 设置一个固定的价差值(单位:USDC)
|
||||||
|
- **最小价差**:当 K 线价差 ≥ 设定值时触发
|
||||||
|
- 示例:设定 30,价差 ≥ 30 触发 ✅,价差 < 30 不触发
|
||||||
|
- **最大价差**:当 K 线价差 ≤ 设定值时触发
|
||||||
|
- 示例:设定 50,价差 ≤ 50 触发 ✅,价差 > 50 不触发
|
||||||
|
- **适合**:您有明确的价差阈值
|
||||||
|
|
||||||
|
**模式三:自动 (AUTO)**
|
||||||
|
- 系统根据历史 20 根 K 线自动计算基准价差
|
||||||
|
- 计算逻辑:
|
||||||
|
1. 获取最近 20 根 K 线(与策略周期一致)
|
||||||
|
2. 按方向筛选(Up 方向只看上涨的 K 线,Down 方向只看下跌的 K 线)
|
||||||
|
3. 剔除异常值(使用 IQR 方法)
|
||||||
|
4. 计算平均价差 × 0.8 作为有效价差
|
||||||
|
- **最小价差**:K 线价差 ≥ 有效价差时触发
|
||||||
|
- **最大价差**:K 线价差 ≤ 有效价差时触发
|
||||||
|
- **适合**:希望根据历史数据自动调整,无需手动设具体数值
|
||||||
|
|
||||||
|
**价差说明**:
|
||||||
|
- 价差 = |收盘价 - 开盘价|(币安 BTC/USDC 当根 K 线)
|
||||||
|
- 例如:开盘价 50000,收盘价 50030,价差 = 30
|
||||||
|
- 价差越大,说明该周期内价格波动越大
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、配置示例
|
||||||
|
|
||||||
|
### 示例1:简单策略(5分钟市场)
|
||||||
|
|
||||||
|
**场景**:在 5 分钟市场的最后 2 分钟,如果价格低于 0.60,自动买入 10 USDC
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```
|
||||||
|
账户:账户A
|
||||||
|
策略名称:BTC 5分钟简单策略
|
||||||
|
市场:btc-updown-5m
|
||||||
|
时间窗口:3分0秒 ~ 5分0秒
|
||||||
|
最低价:0.00
|
||||||
|
最高价:0.60
|
||||||
|
投入方式:固定金额
|
||||||
|
固定金额:10 USDC
|
||||||
|
价差模式:无
|
||||||
|
启用状态:开启
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 周期开始后 0~3 分钟:不监听
|
||||||
|
- 周期开始后 3~5 分钟:如果价格 ≤ 0.60,自动买入 10 USDC
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 示例2:比例投入策略(15分钟市场)
|
||||||
|
|
||||||
|
**场景**:在 15 分钟市场的中段(5~10分钟),如果价格在 0.40~0.70 之间,投入账户余额的 15%
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```
|
||||||
|
账户:账户B
|
||||||
|
策略名称:BTC 15分钟比例策略
|
||||||
|
市场:btc-updown-15m
|
||||||
|
时间窗口:5分0秒 ~ 10分0秒
|
||||||
|
最低价:0.40
|
||||||
|
最高价:0.70
|
||||||
|
投入方式:按比例
|
||||||
|
比例:15%
|
||||||
|
价差模式:无
|
||||||
|
启用状态:开启
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 假设账户余额 100 USDC
|
||||||
|
- 周期开始后 5~10 分钟:如果价格在 0.40~0.70 之间,自动买入约 15 USDC(100 × 15%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 示例3:带价差过滤的策略(15分钟市场)
|
||||||
|
|
||||||
|
**场景**:在 15 分钟市场的后段(10~14分钟),如果价格在 0.50~0.80 之间,且币安价差 ≥ 50,投入 20 USDC
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```
|
||||||
|
账户:账户C
|
||||||
|
策略名称:BTC 15分钟价差策略
|
||||||
|
市场:btc-updown-15m
|
||||||
|
时间窗口:10分0秒 ~ 14分0秒
|
||||||
|
最低价:0.50
|
||||||
|
最高价:0.80
|
||||||
|
投入方式:固定金额
|
||||||
|
固定金额:20 USDC
|
||||||
|
价差模式:固定
|
||||||
|
价差方向:最小价差
|
||||||
|
价差值:50
|
||||||
|
启用状态:开启
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 周期开始后 10~14 分钟:同时满足以下条件才触发
|
||||||
|
1. 价格在 0.50~0.80 之间 ✅
|
||||||
|
2. 价差方向为「最小价差」且币安价差 ≥ 50 ✅
|
||||||
|
- 如果价差只有 30,即使价格满足也不会触发
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 示例4:自动价差策略(15分钟市场)
|
||||||
|
|
||||||
|
**场景**:在 15 分钟市场的前段(2~8分钟),如果价格在 0.30~0.90 之间,投入账户余额的 20%,价差由系统自动计算
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```
|
||||||
|
账户:账户D
|
||||||
|
策略名称:BTC 15分钟自动价差策略
|
||||||
|
市场:btc-updown-15m
|
||||||
|
时间窗口:2分0秒 ~ 8分0秒
|
||||||
|
最低价:0.30
|
||||||
|
最高价:0.90
|
||||||
|
投入方式:按比例
|
||||||
|
比例:20%
|
||||||
|
价差模式:自动
|
||||||
|
价差方向:最小价差
|
||||||
|
启用状态:开启
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 系统会根据历史 20 根 K 线自动计算有效价差
|
||||||
|
- 周期开始后 2~8 分钟:同时满足以下条件才触发
|
||||||
|
1. 价格在 0.30~0.90 之间 ✅
|
||||||
|
2. 价差方向为「最小价差」且币安价差 ≥ 系统计算的有效价差 ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、常见问题
|
||||||
|
|
||||||
|
### Q1:策略什么时候会触发?
|
||||||
|
|
||||||
|
**A**:需要同时满足以下条件:
|
||||||
|
1. ✅ 当前时间在时间窗口内
|
||||||
|
2. ✅ 市场价格在 [最低价, 最高价] 区间内
|
||||||
|
3. ✅ 本周期尚未触发过(每周期最多触发一次)
|
||||||
|
4. ✅ 如果设置了价差过滤,币安价差与价差方向需同时满足条件
|
||||||
|
|
||||||
|
### Q2:为什么我的策略没有触发?
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
1. **时间窗口不对**:当前时间不在设定的时间窗口内
|
||||||
|
2. **价格不在区间**:市场价格不在 [最低价, 最高价] 范围内
|
||||||
|
3. **本周期已触发**:该周期已经触发过一次,不会再触发
|
||||||
|
4. **价差不满足**:如果设置了价差过滤,币安价差或价差方向未满足要求
|
||||||
|
5. **账户余额不足**:账户余额小于设定的投入金额
|
||||||
|
6. **策略未启用**:检查策略的启用状态是否为"开启"
|
||||||
|
|
||||||
|
### Q3:每周期最多触发一次是什么意思?
|
||||||
|
|
||||||
|
**A**:每个周期(5分钟或15分钟)内,即使多次满足条件,也只下单一次。
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
- 15分钟市场,周期从 10:00 开始
|
||||||
|
- 10:05 时价格满足条件,触发下单 ✅
|
||||||
|
- 10:08 时价格再次满足条件,但不会再次下单(本周期已触发)
|
||||||
|
- 10:15 开始新周期,可以再次触发
|
||||||
|
|
||||||
|
### Q4:固定金额和按比例有什么区别?
|
||||||
|
|
||||||
|
**固定金额**:
|
||||||
|
- 每次触发都投入相同金额
|
||||||
|
- 例如:设置 50 USDC,每次都是 50 USDC
|
||||||
|
- 需要确保账户余额充足
|
||||||
|
|
||||||
|
**按比例**:
|
||||||
|
- 每次触发时按账户余额的百分比投入
|
||||||
|
- 例如:设置 10%,账户有 100 USDC 时投入 10 USDC,余额变为 90 USDC 后下次投入 9 USDC
|
||||||
|
- 自动适应余额变化
|
||||||
|
|
||||||
|
### Q5:价差过滤功能有什么用?
|
||||||
|
|
||||||
|
**A**:价差过滤根据币安 BTC/USDC 的 K 线波动决定是否触发,支持两种方向。
|
||||||
|
|
||||||
|
**最小价差**(价差 ≥ 设定值才触发):
|
||||||
|
- 波动太小时不触发,避免在波动不足时进场
|
||||||
|
- 例如:设定 30,只有价差 ≥ 30 才触发
|
||||||
|
|
||||||
|
**最大价差**(价差 ≤ 设定值才触发):
|
||||||
|
- 波动太大时不触发,避免在波动过大、风险高时进场
|
||||||
|
- 例如:设定 50,只有价差 ≤ 50 才触发
|
||||||
|
|
||||||
|
**三种模式选择建议**:
|
||||||
|
- **无**:不关心币安价格波动,只看 Polymarket 价格
|
||||||
|
- **固定**:您知道期望的价差阈值(配合最小/最大价差方向使用)
|
||||||
|
- **自动**:希望根据历史数据自动计算有效价差,无需手动设具体数值
|
||||||
|
|
||||||
|
### Q6:为什么建议使用单独的钱包?
|
||||||
|
|
||||||
|
**A**:避免以下问题:
|
||||||
|
1. **余额变化**:如果钱包同时用于手动交易,余额变化可能影响策略执行
|
||||||
|
2. **仓位冲突**:手动交易和策略交易可能产生冲突
|
||||||
|
3. **管理混乱**:难以区分哪些订单是策略产生的,哪些是手动产生的
|
||||||
|
|
||||||
|
**建议**:创建一个专门的钱包,只用于加密价差策略。
|
||||||
|
|
||||||
|
### Q7:下单价格为什么是固定的 0.99?
|
||||||
|
|
||||||
|
**A**:这是策略的设计特点:
|
||||||
|
- 0.99 是市场中的最高价格(接近 1.0)
|
||||||
|
- 以最高价买入可以确保订单快速成交
|
||||||
|
- 虽然买入价格较高,但策略的核心是捕捉市场波动,而非追求最优价格
|
||||||
|
|
||||||
|
### Q8:策略需要依赖自动赎回功能吗?
|
||||||
|
|
||||||
|
**A**:是的,加密价差策略依赖自动赎回功能。
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 策略下单后会形成仓位
|
||||||
|
- 这些仓位需要在市场结算后自动赎回
|
||||||
|
- 如果未配置自动赎回,仓位可能无法及时赎回
|
||||||
|
|
||||||
|
**配置要求**:
|
||||||
|
- 在「系统设置」中配置 Builder API Key
|
||||||
|
- 开启自动赎回功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、注意事项
|
||||||
|
|
||||||
|
### 6.1 账户要求
|
||||||
|
|
||||||
|
- ✅ 账户必须配置 API Key、API Secret、API Passphrase
|
||||||
|
- ✅ 账户必须有足够的 USDC 余额
|
||||||
|
- ✅ 建议使用专门的钱包,避免与其他操作冲突
|
||||||
|
|
||||||
|
### 6.2 时间窗口设置
|
||||||
|
|
||||||
|
- ⚠️ 开始时间必须 ≤ 结束时间
|
||||||
|
- ⚠️ 时间窗口不能超出周期长度(5分钟市场 ≤ 5分钟,15分钟市场 ≤ 15分钟)
|
||||||
|
- ⚠️ 建议设置合理的时间窗口,避免在周期开始或结束时触发
|
||||||
|
|
||||||
|
### 6.3 价格区间设置
|
||||||
|
|
||||||
|
- ⚠️ 最低价必须 ≤ 最高价
|
||||||
|
- ⚠️ 价格范围是 0~1 之间的小数
|
||||||
|
- ⚠️ 建议根据市场情况设置合理的价格区间
|
||||||
|
|
||||||
|
### 6.4 投入金额设置
|
||||||
|
|
||||||
|
- ⚠️ 最小下单金额:至少 1 USDC
|
||||||
|
- ⚠️ 确保账户余额充足,避免下单失败
|
||||||
|
- ⚠️ 按比例模式:注意账户余额变化对投入金额的影响
|
||||||
|
|
||||||
|
### 6.5 价差过滤设置
|
||||||
|
|
||||||
|
- ⚠️ 价差方向:最小价差为「≥ 触发」,最大价差为「≤ 触发」,请按需求选择
|
||||||
|
- ⚠️ 固定模式:需要填写合理的价差值(单位:USDC)
|
||||||
|
- ⚠️ 自动模式:系统会在周期内按窗口进度自动计算有效价差,无需手动设置
|
||||||
|
- ⚠️ 价差设定过严(最小价差设得过大或最大价差设得过小)可能导致策略难以触发
|
||||||
|
|
||||||
|
### 6.6 其他注意事项
|
||||||
|
|
||||||
|
- ⚠️ 策略创建后默认启用,如需暂停可以关闭"启用状态"
|
||||||
|
- ⚠️ 每周期最多触发一次,请合理设置触发条件
|
||||||
|
- ⚠️ 策略依赖自动赎回功能,请确保已配置 Builder API Key
|
||||||
|
- ⚠️ 建议定期查看触发记录,了解策略执行情况
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、策略管理
|
||||||
|
|
||||||
|
### 7.1 查看策略列表
|
||||||
|
|
||||||
|
在「加密价差策略」页面可以查看所有策略:
|
||||||
|
- 策略名称
|
||||||
|
- 市场信息
|
||||||
|
- 时间窗口
|
||||||
|
- 价格区间
|
||||||
|
- 投入方式
|
||||||
|
- 启用状态
|
||||||
|
- 最后触发时间
|
||||||
|
- 总收益、胜率等统计信息
|
||||||
|
|
||||||
|
### 7.2 查看触发记录
|
||||||
|
|
||||||
|
点击策略可以查看详细的触发记录:
|
||||||
|
- 触发时间
|
||||||
|
- 市场价格
|
||||||
|
- 投入金额
|
||||||
|
- 订单ID
|
||||||
|
- 订单状态(成功/失败)
|
||||||
|
- 结算信息(盈亏、胜率等)
|
||||||
|
|
||||||
|
### 7.3 编辑策略
|
||||||
|
|
||||||
|
可以随时修改策略参数:
|
||||||
|
- 时间窗口
|
||||||
|
- 价格区间
|
||||||
|
- 投入方式
|
||||||
|
- 价差过滤(模式、方向、数值)
|
||||||
|
- 启用状态
|
||||||
|
|
||||||
|
**注意**:修改后的策略会在下一个周期生效。
|
||||||
|
|
||||||
|
### 7.4 删除策略
|
||||||
|
|
||||||
|
删除策略后:
|
||||||
|
- 策略配置会被删除
|
||||||
|
- 历史触发记录会保留
|
||||||
|
- 已下单的订单不受影响
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、总结
|
||||||
|
|
||||||
|
加密价差策略是一个强大的自动化交易工具,可以帮助您:
|
||||||
|
|
||||||
|
1. **自动化交易**:无需手动盯盘,系统自动执行
|
||||||
|
2. **精准控制**:通过时间窗口和价格区间精确控制触发条件
|
||||||
|
3. **灵活配置**:支持比例和固定金额两种投入方式
|
||||||
|
4. **风险过滤**:通过价差过滤(最小价差/最大价差)控制波动条件
|
||||||
|
|
||||||
|
**使用建议**:
|
||||||
|
- 初次使用建议从简单策略开始(无价差过滤)
|
||||||
|
- 熟悉后再尝试添加价差过滤功能
|
||||||
|
- 定期查看触发记录,根据实际情况调整策略参数
|
||||||
|
- 使用专门的钱包,避免与其他操作冲突
|
||||||
|
|
||||||
|
**祝您交易顺利!** 🚀
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Neg Risk 赎回与对应 JS/TS 代码说明
|
||||||
|
|
||||||
|
## Neg Risk 特殊逻辑
|
||||||
|
|
||||||
|
### 1. 赎回(Redeem)
|
||||||
|
|
||||||
|
- **普通市场**:仓位由 **USDC.e**(Bridged USDC)抵押,调用 CTF 的 `redeemPositions(collateralToken, parentCollectionId, conditionId, indexSets)` 时,`collateralToken` 为 USDC.e 地址(Polygon: `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`)。
|
||||||
|
- **Neg Risk 市场**:仓位由 **WrappedCollateral** 抵押(neg-risk-ctf-adapter 设计),同一笔赎回必须使用 WrappedCollateral 地址(Polygon: `0x3A3BD7bb9528E159577F7C2e685CC81A765002E2`),否则链上找不到对应仓位,会得到 payout 0。
|
||||||
|
|
||||||
|
本项目中:通过 Gamma API 的 `negRisk` / `negRiskOther` 判断市场类型,赎回时对 Neg Risk 市场传 `isNegRisk=true`,在 `createRedeemTx` 中选用 WrappedCollateral 作为 `collateralToken`。
|
||||||
|
|
||||||
|
### 2. 下单/签约(Order Signing)
|
||||||
|
|
||||||
|
- **普通市场**:使用标准 CTF Exchange 合约签约。
|
||||||
|
- **Neg Risk 市场**:必须使用 **Neg Risk CTF Exchange** 合约签约,否则 CLOB 返回 invalid signature。
|
||||||
|
|
||||||
|
见 `OrderSigningService.getExchangeContract(negRisk)`、`CopyOrderTrackingService` 中按 `getNegRiskByConditionId` 选择 exchange。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对应的 JS/TS 代码位置
|
||||||
|
|
||||||
|
| 功能 | 仓库/来源 | 路径或说明 |
|
||||||
|
|------|-----------|------------|
|
||||||
|
| Relayer 执行、Safe 交易提交 | [Polymarket/builder-relayer-client](https://github.com/Polymarket/builder-relayer-client) | `src/client.ts`(`execute`)、`src/encode/safe.ts`(MultiSend `createSafeMultisendTransaction`) |
|
||||||
|
| 链与合约配置 | builder-relayer-client | `src/config/index.ts`(Polygon/Amoy 的 SafeMultisend 等) |
|
||||||
|
| 赎回 calldata 构建 | **官方仓库无** | 官方库只负责执行传入的 `Transaction[]`,不包含 `createRedeemTx` 或 redeem 工具函数 |
|
||||||
|
| 社区赎回示例(单一 collateral) | [Gist: redeem-positions](https://gist.github.com/Waawzer/5cdff342767265c2637e21607d03f6eb) | 使用 `collateralToken` 调用 `redeemPositions`,**未区分 Neg Risk**(全部用同一 collateral,如 USDC) |
|
||||||
|
| Neg Risk 合约与 WrappedCollateral | [Polymarket/neg-risk-ctf-adapter](https://github.com/Polymarket/neg-risk-ctf-adapter) | README、`addresses.json`(137 链上 negRiskWrappedCollateral 等地址) |
|
||||||
|
| 市场是否 Neg Risk | Gamma API | 市场/事件的 `negRisk`、`negRiskOther` 字段,本项目中通过 `MarketService.getNegRiskByConditionId` 查询 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 赎回后自动解包 WCOL → USDC.e
|
||||||
|
|
||||||
|
Neg Risk 赎回到账为 **WCOL**,若不解包则余额显示为 WCOL 而非 USDC.e。本项目中由**轮询任务**统一解包,赎回流程内不再等待确认与解包:
|
||||||
|
|
||||||
|
- **WcolUnwrapJobService**:每 20 秒轮询一次(`@Scheduled(fixedRate = 20_000)`),遍历所有账户的代理地址,若 WCOL 余额 > 0 则调用 **BlockchainService.unwrapWcolForProxy** 解包为 USDC.e。同一时间仅允许单次执行;若上次执行未结束则本次忽略。
|
||||||
|
- **AccountService.redeemPositions**:只负责赎回,不再在流程内等待交易确认或执行解包;解包由上述 Job 在后续轮询中处理(含重启后未解包、解包失败重试等)。
|
||||||
|
- **BlockchainService**:提供 `getWcolBalance(proxyAddress)`、`unwrapWcolForProxy(...)`;**RelayClientService** 提供 `createUnwrapWcolTx(toAddress, amountWei)`。
|
||||||
|
- **Safe 与 Magic 解包逻辑一致**:均使用同一 `createUnwrapWcolTx(proxyAddress, balance)` 与 `execute(privateKey, proxyAddress, unwrapTx, walletType)`;Safe 走 execTransaction(或 Builder Relayer SAFE),Magic 走 Builder Relayer PROXY(encodeProxyTransactionData),最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount),USDC.e 转入该代理地址。
|
||||||
|
|
||||||
|
## 小结
|
||||||
|
|
||||||
|
- **Neg Risk 特殊逻辑**:赎回用 WrappedCollateral、下单用 Neg Risk Exchange;均由「是否为 Neg Risk 市场」分支处理。
|
||||||
|
- **对应 JS 代码**:执行与 MultiSend 在 **builder-relayer-client**;赎回参数与 calldata 在**应用层**构建,官方无现成 redeem 工具;Neg Risk 的抵押品与合约见 **neg-risk-ctf-adapter** 与 **Gamma API**。
|
||||||
+24
-11
@@ -29,12 +29,14 @@ import CopyTradingSellOrders from './pages/CopyTradingSellOrders'
|
|||||||
import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
|
import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
|
||||||
import FilteredOrdersList from './pages/FilteredOrdersList'
|
import FilteredOrdersList from './pages/FilteredOrdersList'
|
||||||
import SystemSettings from './pages/SystemSettings'
|
import SystemSettings from './pages/SystemSettings'
|
||||||
|
import NotificationSettingsPage from './pages/NotificationSettingsPage'
|
||||||
import ApiHealthStatus from './pages/ApiHealthStatus'
|
import ApiHealthStatus from './pages/ApiHealthStatus'
|
||||||
import RpcNodeSettings from './pages/RpcNodeSettings'
|
import RpcNodeSettings from './pages/RpcNodeSettings'
|
||||||
import Announcements from './pages/Announcements'
|
import Announcements from './pages/Announcements'
|
||||||
import BacktestList from './pages/BacktestList'
|
import BacktestList from './pages/BacktestList'
|
||||||
import BacktestDetail from './pages/BacktestDetail'
|
import BacktestDetail from './pages/BacktestDetail'
|
||||||
import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
|
import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
|
||||||
|
import CryptoTailMonitor from './pages/CryptoTailMonitor'
|
||||||
import { wsManager } from './services/websocket'
|
import { wsManager } from './services/websocket'
|
||||||
import type { OrderPushMessage } from './types'
|
import type { OrderPushMessage } from './types'
|
||||||
import { apiService } from './services/api'
|
import { apiService } from './services/api'
|
||||||
@@ -115,28 +117,37 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先使用订单详情中的数据,如果没有则使用 WebSocket 消息中的数据
|
// 实际成交价 = original_size*price/size_matched;有成交时数量用 size_matched
|
||||||
const price = orderDetail ? parseFloat(orderDetail.price).toFixed(4) : parseFloat(order.price).toFixed(4)
|
const size = orderDetail ? orderDetail.size : order.original_size
|
||||||
const size = orderDetail ? parseFloat(orderDetail.size).toFixed(2) : parseFloat(order.original_size).toFixed(2)
|
const filled = orderDetail ? orderDetail.filled : order.size_matched
|
||||||
const filled = orderDetail ? parseFloat(orderDetail.filled).toFixed(2) : parseFloat(order.size_matched).toFixed(2)
|
const sizeNum = parseFloat(size).toFixed(2)
|
||||||
|
const filledNum = parseFloat(filled).toFixed(2)
|
||||||
|
const hasFilled = parseFloat(filled) > 0
|
||||||
|
const price = orderDetail
|
||||||
|
? (orderDetail.avgFilledPrice ?? orderDetail.price)
|
||||||
|
: (hasFilled
|
||||||
|
? (parseFloat(order.original_size) * parseFloat(order.price) / parseFloat(order.size_matched)).toString()
|
||||||
|
: order.price)
|
||||||
|
const priceStr = parseFloat(price).toFixed(4)
|
||||||
const status = orderDetail?.status || 'UNKNOWN'
|
const status = orderDetail?.status || 'UNKNOWN'
|
||||||
|
// 有成交时展示数量用 size_matched(filled),否则用 original_size
|
||||||
|
const displaySize = (orderDetail?.avgFilledPrice || (orderDetail == null && hasFilled)) ? filledNum : sizeNum
|
||||||
|
|
||||||
// 构建描述信息
|
// 构建描述信息
|
||||||
let description = `${t('order.market')}: ${marketName}\n${sideText} ${size} @ ${price}`
|
let description = `${t('order.market')}: ${marketName}\n${sideText} ${displaySize} @ ${priceStr}`
|
||||||
|
|
||||||
// 如果有订单详情,显示更详细的信息
|
// 如果有订单详情,显示更详细的信息
|
||||||
if (orderDetail) {
|
if (orderDetail) {
|
||||||
description += `\n${t('order.status')}: ${status}`
|
description += `\n${t('order.status')}: ${status}`
|
||||||
if (parseFloat(filled) > 0) {
|
if (parseFloat(filledNum) > 0) {
|
||||||
description += ` | ${t('order.filled')}: ${filled}`
|
description += ` | ${t('order.filled')}: ${filledNum}`
|
||||||
}
|
}
|
||||||
const remaining = (parseFloat(size) - parseFloat(filled)).toFixed(2)
|
const remaining = (parseFloat(sizeNum) - parseFloat(filledNum)).toFixed(2)
|
||||||
if (parseFloat(remaining) > 0) {
|
if (parseFloat(remaining) > 0) {
|
||||||
description += ` | ${t('order.remaining')}: ${remaining}`
|
description += ` | ${t('order.remaining')}: ${remaining}`
|
||||||
}
|
}
|
||||||
} else if (order.type === 'UPDATE' && parseFloat(order.size_matched) > 0) {
|
} else if (order.type === 'UPDATE' && parseFloat(order.size_matched) > 0) {
|
||||||
// 如果没有订单详情,使用 WebSocket 消息中的已成交数量
|
description += `\n${t('order.filled')}: ${filledNum}`
|
||||||
description += `\n${t('order.filled')}: ${filled}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据订单类型选择通知类型
|
// 根据订单类型选择通知类型
|
||||||
@@ -252,6 +263,7 @@ function App() {
|
|||||||
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
|
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
|
||||||
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
|
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
|
||||||
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
|
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
|
||||||
|
<Route path="/crypto-tail-monitor" element={<ProtectedRoute><CryptoTailMonitor /></ProtectedRoute>} />
|
||||||
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
|
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
|
||||||
{/* 保留旧路由以保持向后兼容 */}
|
{/* 保留旧路由以保持向后兼容 */}
|
||||||
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
|
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
|
||||||
@@ -266,6 +278,7 @@ function App() {
|
|||||||
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
|
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
|
||||||
<Route path="/announcements" element={<ProtectedRoute><Announcements /></ProtectedRoute>} />
|
<Route path="/announcements" element={<ProtectedRoute><Announcements /></ProtectedRoute>} />
|
||||||
<Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></ProtectedRoute>} />
|
<Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></ProtectedRoute>} />
|
||||||
|
<Route path="/system-settings/notification" element={<ProtectedRoute><NotificationSettingsPage /></ProtectedRoute>} />
|
||||||
<Route path="/system-settings/rpc-nodes" element={<ProtectedRoute><RpcNodeSettings /></ProtectedRoute>} /> <Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
|
<Route path="/system-settings/rpc-nodes" element={<ProtectedRoute><RpcNodeSettings /></ProtectedRoute>} /> <Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
|
||||||
|
|
||||||
{/* 默认重定向到登录页 */}
|
{/* 默认重定向到登录页 */}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import type { ProxyOption } from '../types'
|
import type { ProxyOption } from '../types'
|
||||||
|
import AccountSetupGuideModal from './AccountSetupGuideModal'
|
||||||
|
|
||||||
type ImportType = 'privateKey' | 'mnemonic'
|
type ImportType = 'privateKey' | 'mnemonic'
|
||||||
|
|
||||||
@@ -41,6 +42,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
|||||||
const [selectedProxyType, setSelectedProxyType] = useState<string>('')
|
const [selectedProxyType, setSelectedProxyType] = useState<string>('')
|
||||||
const [loadingProxyOptions, setLoadingProxyOptions] = useState<boolean>(false)
|
const [loadingProxyOptions, setLoadingProxyOptions] = useState<boolean>(false)
|
||||||
const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址
|
const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址
|
||||||
|
const [setupModalVisible, setSetupModalVisible] = useState<boolean>(false)
|
||||||
|
const [setupStatus, setSetupStatus] = useState<any>(null)
|
||||||
|
const [importedAccountId, setImportedAccountId] = useState<number | undefined>(undefined)
|
||||||
|
|
||||||
// 当私钥输入时,自动推导地址(不支持换行,自动去除换行符)
|
// 当私钥输入时,自动推导地址(不支持换行,自动去除换行符)
|
||||||
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
@@ -254,11 +258,34 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
|||||||
|
|
||||||
// 获取新添加的账户ID(通过API获取,因为store可能还没更新)
|
// 获取新添加的账户ID(通过API获取,因为store可能还没更新)
|
||||||
const accountsResponse = await apiService.accounts.list()
|
const accountsResponse = await apiService.accounts.list()
|
||||||
|
let accountId: number | undefined = undefined
|
||||||
if (accountsResponse.data.code === 0 && accountsResponse.data.data) {
|
if (accountsResponse.data.code === 0 && accountsResponse.data.data) {
|
||||||
const newAccounts = accountsResponse.data.data.list || []
|
const newAccounts = accountsResponse.data.data.list || []
|
||||||
const newAccount = newAccounts.find((acc: any) => acc.walletAddress === walletAddress)
|
const newAccount = newAccounts.find((acc: any) => acc.walletAddress === walletAddress)
|
||||||
if (newAccount && onSuccess) {
|
if (newAccount) {
|
||||||
onSuccess(newAccount.id)
|
accountId = newAccount.id
|
||||||
|
setImportedAccountId(accountId)
|
||||||
|
|
||||||
|
// 检查账户设置状态
|
||||||
|
let willShowSetupModal = false
|
||||||
|
try {
|
||||||
|
const setupResponse = await apiService.accounts.checkSetupStatus(newAccount.id)
|
||||||
|
if (setupResponse.data.code === 0 && setupResponse.data.data) {
|
||||||
|
const status = setupResponse.data.data
|
||||||
|
setSetupStatus(status)
|
||||||
|
const hasIncomplete = !status.proxyDeployed || !status.tradingEnabled || !status.tokensApproved
|
||||||
|
if (hasIncomplete) {
|
||||||
|
setSetupModalVisible(true)
|
||||||
|
willShowSetupModal = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查账户设置状态失败:', error)
|
||||||
|
}
|
||||||
|
// 未展示设置弹窗时才调用 onSuccess,避免父组件关闭导入弹窗导致设置弹窗被卸载
|
||||||
|
if (!willShowSetupModal && onSuccess) {
|
||||||
|
onSuccess(newAccount.id)
|
||||||
|
}
|
||||||
} else if (onSuccess) {
|
} else if (onSuccess) {
|
||||||
onSuccess(0)
|
onSuccess(0)
|
||||||
}
|
}
|
||||||
@@ -551,6 +578,37 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
|||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
{/* 账户设置引导弹窗 */}
|
||||||
|
<AccountSetupGuideModal
|
||||||
|
visible={setupModalVisible}
|
||||||
|
setupStatus={setupStatus}
|
||||||
|
accountId={importedAccountId}
|
||||||
|
onClose={() => {
|
||||||
|
setSetupModalVisible(false)
|
||||||
|
onSuccess?.(importedAccountId ?? 0)
|
||||||
|
}}
|
||||||
|
onComplete={async () => {
|
||||||
|
// 刷新设置状态
|
||||||
|
if (importedAccountId) {
|
||||||
|
try {
|
||||||
|
const setupResponse = await apiService.accounts.checkSetupStatus(importedAccountId)
|
||||||
|
if (setupResponse.data.code === 0 && setupResponse.data.data) {
|
||||||
|
setSetupStatus(setupResponse.data.data)
|
||||||
|
const status = setupResponse.data.data
|
||||||
|
// 如果所有步骤都完成了,关闭弹窗并通知父组件
|
||||||
|
if (status.proxyDeployed && status.tradingEnabled && status.tokensApproved) {
|
||||||
|
setSetupModalVisible(false)
|
||||||
|
message.success(t('accountSetup.allCompleted.title'))
|
||||||
|
onSuccess?.(importedAccountId ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新设置状态失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Modal, Alert, Space, Button, Typography } from 'antd'
|
||||||
|
import { CheckCircleOutlined, ExclamationCircleOutlined, WalletOutlined } from '@ant-design/icons'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
import AccountSetupStatusBlock from './AccountSetupStatusBlock'
|
||||||
|
import type { SetupStatus } from './AccountSetupStatusBlock'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface AccountSetupGuideModalProps {
|
||||||
|
visible: boolean
|
||||||
|
setupStatus: SetupStatus | null
|
||||||
|
accountId?: number
|
||||||
|
onClose: () => void
|
||||||
|
onComplete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountSetupGuideModal: React.FC<AccountSetupGuideModalProps> = ({
|
||||||
|
visible,
|
||||||
|
setupStatus: _initialStatus,
|
||||||
|
accountId,
|
||||||
|
onClose,
|
||||||
|
onComplete
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
const [allCompleted, setAllCompleted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) setAllCompleted(false)
|
||||||
|
}, [visible, accountId])
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<WalletOutlined style={{ fontSize: '20px', color: '#1890ff' }} />
|
||||||
|
<span>{t('accountSetup.title')}</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={visible}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
{allCompleted ? (
|
||||||
|
<Button type="primary" onClick={onClose} size={isMobile ? 'middle' : 'large'}>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={onClose} size={isMobile ? 'middle' : 'large'}>
|
||||||
|
{t('common.later')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
width={isMobile ? '95%' : 680}
|
||||||
|
style={{ top: isMobile ? 20 : 50 }}
|
||||||
|
destroyOnClose
|
||||||
|
maskClosable={allCompleted}
|
||||||
|
closable
|
||||||
|
>
|
||||||
|
<div style={{ padding: isMobile ? '16px 0' : '24px 0' }}>
|
||||||
|
{allCompleted ? (
|
||||||
|
<Alert
|
||||||
|
message={t('accountSetup.allCompleted.title')}
|
||||||
|
description={t('accountSetup.allCompleted.description')}
|
||||||
|
type="success"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
message={t('accountSetup.incomplete.title')}
|
||||||
|
description={t('accountSetup.incomplete.description')}
|
||||||
|
type="warning"
|
||||||
|
icon={<ExclamationCircleOutlined />}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{accountId != null && accountId > 0 ? (
|
||||||
|
<AccountSetupStatusBlock
|
||||||
|
accountId={accountId}
|
||||||
|
embedded
|
||||||
|
showApprovalDetails
|
||||||
|
onAllCompleted={() => setAllCompleted(true)}
|
||||||
|
onRefresh={onComplete}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">{t('accountSetup.error.description')}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24, padding: '12px', background: '#f5f5f5', borderRadius: '4px' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
{t('accountSetup.help')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountSetupGuideModal
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Card, Steps, Button, Space, Tag, Spin, Typography, message } from 'antd'
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
WalletOutlined,
|
||||||
|
KeyOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
ReloadOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { apiService } from '../services/api'
|
||||||
|
|
||||||
|
const { Paragraph, Text } = Typography
|
||||||
|
|
||||||
|
export interface SetupStatus {
|
||||||
|
proxyDeployed: boolean
|
||||||
|
tradingEnabled: boolean
|
||||||
|
tokensApproved: boolean
|
||||||
|
approvalDetails?: Record<string, string>
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountSetupStatusBlockProps {
|
||||||
|
accountId: number
|
||||||
|
onRefresh?: () => void
|
||||||
|
onAllCompleted?: () => void
|
||||||
|
size?: 'small' | 'default'
|
||||||
|
showApprovalDetails?: boolean
|
||||||
|
/** 嵌入模式:不渲染 Card,仅渲染步骤与授权详情(供弹窗等复用) */
|
||||||
|
embedded?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 步骤 key 与步骤编号对应 */
|
||||||
|
const STEP_KEYS = ['step1', 'step2', 'step3'] as const
|
||||||
|
const stepKeyToNumber = (key: string): number =>
|
||||||
|
STEP_KEYS.indexOf(key as typeof STEP_KEYS[number]) + 1
|
||||||
|
|
||||||
|
const AccountSetupStatusBlock: React.FC<AccountSetupStatusBlockProps> = ({
|
||||||
|
accountId,
|
||||||
|
onRefresh,
|
||||||
|
onAllCompleted,
|
||||||
|
size = 'default',
|
||||||
|
showApprovalDetails = true,
|
||||||
|
embedded = false
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
if (accountId <= 0) return
|
||||||
|
try {
|
||||||
|
const response = await apiService.accounts.checkSetupStatus(accountId)
|
||||||
|
if (response.data.code === 0 && response.data.data) {
|
||||||
|
setSetupStatus(response.data.data)
|
||||||
|
} else {
|
||||||
|
setSetupStatus(null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取账户设置状态失败:', error)
|
||||||
|
setSetupStatus(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchStatus()
|
||||||
|
}, [accountId])
|
||||||
|
|
||||||
|
// 每 5 秒轮询最新状态(首次加载完成后且存在未完成步骤时轮询,全部完成后停止)
|
||||||
|
useEffect(() => {
|
||||||
|
if (accountId <= 0 || setupStatus == null) return
|
||||||
|
const allCompleted =
|
||||||
|
setupStatus.proxyDeployed &&
|
||||||
|
setupStatus.tradingEnabled &&
|
||||||
|
setupStatus.tokensApproved
|
||||||
|
if (allCompleted) return
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
fetchStatus()
|
||||||
|
}, 5000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [accountId, setupStatus?.proxyDeployed, setupStatus?.tradingEnabled, setupStatus?.tokensApproved])
|
||||||
|
|
||||||
|
// 全部完成时通知父组件(供弹窗等关闭或更新用)
|
||||||
|
const allCompleted =
|
||||||
|
setupStatus != null &&
|
||||||
|
setupStatus.proxyDeployed &&
|
||||||
|
setupStatus.tradingEnabled &&
|
||||||
|
setupStatus.tokensApproved
|
||||||
|
useEffect(() => {
|
||||||
|
if (allCompleted) onAllCompleted?.()
|
||||||
|
}, [allCompleted, onAllCompleted])
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
await fetchStatus()
|
||||||
|
onRefresh?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStepAction = async (key: string) => {
|
||||||
|
const stepNum = stepKeyToNumber(key)
|
||||||
|
if (stepNum < 1) return
|
||||||
|
setActionLoading(key)
|
||||||
|
try {
|
||||||
|
const response = await apiService.accounts.executeSetupStep(accountId, stepNum)
|
||||||
|
const res = response.data
|
||||||
|
if (res.code !== 0) {
|
||||||
|
message.error(res.msg || t('accountSetup.actionFailed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = res.data
|
||||||
|
if (data?.redirectUrl) {
|
||||||
|
window.open(data.redirectUrl, '_blank')
|
||||||
|
}
|
||||||
|
if (data?.success !== false) {
|
||||||
|
await fetchStatus()
|
||||||
|
onRefresh?.()
|
||||||
|
if (data?.transactionHash) {
|
||||||
|
message.success(t('accountSetup.actionSuccess'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error(t('accountSetup.actionFailed'))
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !setupStatus) {
|
||||||
|
const loadingContent = (
|
||||||
|
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return embedded ? <div>{loadingContent}</div> : (
|
||||||
|
<Card title={t('accountSetup.title')} size={size}>{loadingContent}</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!setupStatus) {
|
||||||
|
const errorContent = (
|
||||||
|
<>
|
||||||
|
<Text type="secondary">{t('accountSetup.error.description')}</Text>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={handleRefresh}>
|
||||||
|
{t('accountSetup.refresh')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return embedded ? <div>{errorContent}</div> : (
|
||||||
|
<Card title={t('accountSetup.title')} size={size}>{errorContent}</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
key: 'step1',
|
||||||
|
title: t('accountSetup.step1.title'),
|
||||||
|
description: t('accountSetup.step1.description'),
|
||||||
|
icon: <WalletOutlined />,
|
||||||
|
completed: setupStatus.proxyDeployed,
|
||||||
|
actionLabel: t('accountSetup.step1.action')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'step2',
|
||||||
|
title: t('accountSetup.step2.title'),
|
||||||
|
description: t('accountSetup.step2.description'),
|
||||||
|
icon: <KeyOutlined />,
|
||||||
|
completed: setupStatus.tradingEnabled,
|
||||||
|
actionLabel: t('accountSetup.step2.action')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'step3',
|
||||||
|
title: t('accountSetup.step3.title'),
|
||||||
|
description: t('accountSetup.step3.description'),
|
||||||
|
icon: <SafetyOutlined />,
|
||||||
|
completed: setupStatus.tokensApproved,
|
||||||
|
actionLabel: t('accountSetup.step3.action')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const stepsContent = (
|
||||||
|
<>
|
||||||
|
<Steps
|
||||||
|
direction="vertical"
|
||||||
|
current={steps.findIndex(s => !s.completed)}
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
{steps.map((step) => (
|
||||||
|
<Steps.Step
|
||||||
|
key={step.key}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<span>{step.title}</span>
|
||||||
|
{step.completed ? (
|
||||||
|
<Tag color="success" icon={<CheckCircleOutlined />}>
|
||||||
|
{t('accountSetup.completed')}
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="warning" icon={<CloseCircleOutlined />}>
|
||||||
|
{t('accountSetup.pending')}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Paragraph style={{ marginBottom: 8, fontSize: 14, color: '#666' }}>
|
||||||
|
{step.description}
|
||||||
|
</Paragraph>
|
||||||
|
{!step.completed && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
onClick={() => handleStepAction(step.key)}
|
||||||
|
loading={actionLoading === step.key}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
>
|
||||||
|
{step.actionLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
icon={step.icon}
|
||||||
|
status={step.completed ? 'finish' : 'process'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
{showApprovalDetails && setupStatus.approvalDetails && Object.keys(setupStatus.approvalDetails).length > 0 && (
|
||||||
|
<div style={{ marginTop: 16, padding: '12px', background: '#fafafa', borderRadius: 4 }}>
|
||||||
|
<Text strong style={{ display: 'block', marginBottom: 8 }}>{t('accountSetup.approvalDetails.title')}</Text>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
|
{Object.entries(setupStatus.approvalDetails).map(([contract, allowance]) => {
|
||||||
|
const isUnlimited = allowance === 'unlimited'
|
||||||
|
const isApproved = isUnlimited || parseFloat(allowance) > 0
|
||||||
|
const displayText = isUnlimited
|
||||||
|
? t('accountSetup.approvalDetails.unlimited')
|
||||||
|
: isApproved
|
||||||
|
? `${parseFloat(allowance).toFixed(2)} USDC`
|
||||||
|
: t('accountSetup.approvalDetails.notApproved')
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contract}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 13,
|
||||||
|
minHeight: 24
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{t(`accountSetup.approvalDetails.${contract}`) || contract}</span>
|
||||||
|
<span style={{ minWidth: 100, textAlign: 'right' }}>{displayText}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{setupStatus.error && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Text type="danger">{setupStatus.error}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <div style={{ position: 'relative' }}>{stepsContent}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('accountSetup.title')}
|
||||||
|
size={size}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
loading={refreshing}
|
||||||
|
>
|
||||||
|
{t('accountSetup.refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{stepsContent}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountSetupStatusBlock
|
||||||
@@ -21,7 +21,9 @@ import {
|
|||||||
SendOutlined,
|
SendOutlined,
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
NotificationOutlined,
|
NotificationOutlined,
|
||||||
LineChartOutlined
|
LineChartOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
DashboardOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
@@ -75,6 +77,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||||
keys.push('/copy-trading-management')
|
keys.push('/copy-trading-management')
|
||||||
}
|
}
|
||||||
|
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||||
|
keys.push('/crypto-tail-management')
|
||||||
|
}
|
||||||
if (path.startsWith('/system-settings')) {
|
if (path.startsWith('/system-settings')) {
|
||||||
keys.push('/system-settings')
|
keys.push('/system-settings')
|
||||||
}
|
}
|
||||||
@@ -90,6 +95,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||||
keys.push('/copy-trading-management')
|
keys.push('/copy-trading-management')
|
||||||
}
|
}
|
||||||
|
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||||
|
keys.push('/crypto-tail-management')
|
||||||
|
}
|
||||||
if (path.startsWith('/system-settings')) {
|
if (path.startsWith('/system-settings')) {
|
||||||
keys.push('/system-settings')
|
keys.push('/system-settings')
|
||||||
}
|
}
|
||||||
@@ -158,9 +166,21 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '/crypto-tail-strategy',
|
key: '/crypto-tail-management',
|
||||||
icon: <LineChartOutlined />,
|
icon: <LineChartOutlined />,
|
||||||
label: t('menu.cryptoTailStrategy')
|
label: t('menu.cryptoSpreadStrategy'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '/crypto-tail-strategy',
|
||||||
|
icon: <RocketOutlined />,
|
||||||
|
label: t('menu.cryptoTailStrategy')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/crypto-tail-monitor',
|
||||||
|
icon: <DashboardOutlined />,
|
||||||
|
label: t('menu.cryptoTailMonitor')
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '/positions',
|
key: '/positions',
|
||||||
@@ -187,6 +207,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
label: t('menu.systemOverview') || '通用设置'
|
label: t('menu.systemOverview') || '通用设置'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/system-settings/notification',
|
||||||
|
icon: <NotificationOutlined />,
|
||||||
|
label: t('menu.notifications') || '消息推送设置'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: '/system-settings/rpc-nodes',
|
key: '/system-settings/rpc-nodes',
|
||||||
icon: <ApiOutlined />,
|
icon: <ApiOutlined />,
|
||||||
@@ -230,7 +255,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
|
|
||||||
const handleMenuClick = ({ key }: { key: string }) => {
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
// 如果是父菜单,不导航(但 /system-settings 作为子菜单项时可以导航)
|
// 如果是父菜单,不导航(但 /system-settings 作为子菜单项时可以导航)
|
||||||
if (key === '/copy-trading-management') {
|
if (key === '/copy-trading-management' || key === '/crypto-tail-management') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +313,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
verticalAlign: 'middle'
|
verticalAlign: 'middle'
|
||||||
}}
|
}}
|
||||||
title={hasUpdate ? '有新版本可用,点击前往系统更新' : '当前已是最新版本'}
|
title={hasUpdate ? t('systemUpdate.versionTooltipNew') : t('systemUpdate.versionTooltipLatest')}
|
||||||
>
|
>
|
||||||
{getVersionInfo().gitTag || `v${getVersionText()}`}
|
{getVersionInfo().gitTag || `v${getVersionText()}`}
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -410,7 +435,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
verticalAlign: 'middle'
|
verticalAlign: 'middle'
|
||||||
}}
|
}}
|
||||||
title={hasUpdate ? '有新版本可用,点击前往系统更新' : '当前已是最新版本'}
|
title={hasUpdate ? t('systemUpdate.versionTooltipNew') : t('systemUpdate.versionTooltipLatest')}
|
||||||
>
|
>
|
||||||
{getVersionInfo().gitTag || `v${getVersionText()}`}
|
{getVersionInfo().gitTag || `v${getVersionText()}`}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"success": "Success",
|
"success": "Success",
|
||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
"later": "Later",
|
||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
@@ -41,7 +42,8 @@
|
|||||||
"pageOf": "Page",
|
"pageOf": "Page",
|
||||||
"ascending": "Ascending",
|
"ascending": "Ascending",
|
||||||
"descending": "Descending",
|
"descending": "Descending",
|
||||||
"day": "day"
|
"day": "day",
|
||||||
|
"orders": "orders"
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Account Management",
|
"title": "Account Management",
|
||||||
@@ -175,7 +177,8 @@
|
|||||||
"updateFailed": "Failed to update account",
|
"updateFailed": "Failed to update account",
|
||||||
"getDetailFailedForEdit": "Failed to get account detail",
|
"getDetailFailedForEdit": "Failed to get account detail",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"fetchFailed": "Failed to get account list"
|
"fetchFailed": "Failed to get account list",
|
||||||
|
"noData": "No account data"
|
||||||
},
|
},
|
||||||
"accountImport": {
|
"accountImport": {
|
||||||
"title": "Import Account",
|
"title": "Import Account",
|
||||||
@@ -237,6 +240,51 @@
|
|||||||
"select": "Select this proxy address"
|
"select": "Select this proxy address"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"accountSetup": {
|
||||||
|
"title": "Account Setup Check",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"refresh": "Refresh Status",
|
||||||
|
"allCompleted": {
|
||||||
|
"title": "All Setup Completed",
|
||||||
|
"description": "Your account is ready to use all features."
|
||||||
|
},
|
||||||
|
"incomplete": {
|
||||||
|
"title": "Account Setup Incomplete",
|
||||||
|
"description": "Please complete the following setup steps to ensure your account works properly."
|
||||||
|
},
|
||||||
|
"step1": {
|
||||||
|
"title": "Deploy Proxy Wallet",
|
||||||
|
"description": "Proxy wallet is required for trading on Polymarket. Safe accounts can deploy with one click; Magic accounts will be redirected to Polymarket.",
|
||||||
|
"action": "Deploy Proxy Wallet"
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"title": "Enable Trading",
|
||||||
|
"description": "API credentials are required for trading. Click the button below to let the system automatically obtain and save the API Key.",
|
||||||
|
"action": "Enable Trading (One-Click)"
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
|
"title": "Approve Tokens",
|
||||||
|
"description": "You need to authorize the proxy wallet to use your USDC tokens. Click the button below to complete token approval automatically.",
|
||||||
|
"action": "Approve Tokens (One-Click)"
|
||||||
|
},
|
||||||
|
"approvalDetails": {
|
||||||
|
"title": "Token Approval Details",
|
||||||
|
"CTF_CONTRACT": "CTF Contract",
|
||||||
|
"CTF_EXCHANGE": "CTF Exchange",
|
||||||
|
"NEG_RISK_EXCHANGE": "Neg Risk Exchange",
|
||||||
|
"NEG_RISK_ADAPTER": "Neg Risk Adapter",
|
||||||
|
"notApproved": "Not Approved",
|
||||||
|
"unlimited": "Unlimited"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Check Failed",
|
||||||
|
"description": "Unable to check account setup status, please try again later."
|
||||||
|
},
|
||||||
|
"actionSuccess": "Operation successful",
|
||||||
|
"actionFailed": "Operation failed, please try again later",
|
||||||
|
"help": "Tip: After completing the setup, click the \"Refresh Status\" button to update the check results. If all steps are completed, you can use account features normally."
|
||||||
|
},
|
||||||
"leader": {
|
"leader": {
|
||||||
"title": "Leader Management",
|
"title": "Leader Management",
|
||||||
"leaderName": "Leader Name",
|
"leaderName": "Leader Name",
|
||||||
@@ -266,7 +314,9 @@
|
|||||||
"leaders": "Leader Management",
|
"leaders": "Leader Management",
|
||||||
"templates": "Templates",
|
"templates": "Templates",
|
||||||
"copyTradingConfig": "Copy Trading Config",
|
"copyTradingConfig": "Copy Trading Config",
|
||||||
"cryptoTailStrategy": "Tail Strategy",
|
"cryptoSpreadStrategy": "Crypto Spread Strategy",
|
||||||
|
"cryptoTailStrategy": "Strategy Config",
|
||||||
|
"cryptoTailMonitor": "Real-time Monitor",
|
||||||
"positions": "Position Management",
|
"positions": "Position Management",
|
||||||
"backtest": "Backtest",
|
"backtest": "Backtest",
|
||||||
"statistics": "Statistics",
|
"statistics": "Statistics",
|
||||||
@@ -372,6 +422,40 @@
|
|||||||
"saveFailed": "Failed to save auto redeem configuration"
|
"saveFailed": "Failed to save auto redeem configuration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"systemUpdate": {
|
||||||
|
"title": "System Update",
|
||||||
|
"currentVersion": "Current Version",
|
||||||
|
"ready": "Ready",
|
||||||
|
"hasNewVersion": "New version available: {{version}}",
|
||||||
|
"alreadyLatest": "You are on the latest version",
|
||||||
|
"checkFailed": "Failed to check for updates",
|
||||||
|
"confirmTitle": "Confirm Update",
|
||||||
|
"confirmContent1": "Update to version {{version}}?",
|
||||||
|
"confirmContent2": "The system will be temporarily unavailable during the update (about 30-60 seconds).",
|
||||||
|
"confirmContent3": "The page will refresh automatically when the update completes.",
|
||||||
|
"okText": "Update Now",
|
||||||
|
"cancelText": "Cancel",
|
||||||
|
"updateStarted": "Update started, please wait...",
|
||||||
|
"updateFailedWithMessage": "Update failed: {{message}}",
|
||||||
|
"updateSuccessRefresh": "Update successful! Page will refresh in 3 seconds...",
|
||||||
|
"needAdmin": "Admin permission required to perform update",
|
||||||
|
"startFailed": "Failed to start update",
|
||||||
|
"updating": "System is updating",
|
||||||
|
"updateFailedTitle": "Update Failed",
|
||||||
|
"checkUpdate": "Check for Updates",
|
||||||
|
"newVersionFound": "New Version Available",
|
||||||
|
"publishedAt": "Published At",
|
||||||
|
"releaseNotes": "Release Notes",
|
||||||
|
"upgradeNow": "Upgrade to v{{version}} Now",
|
||||||
|
"usageTitle": "Instructions",
|
||||||
|
"usage1": "Click \"Check for Updates\" to see if a new version is available",
|
||||||
|
"usage2": "Update takes about 30-60 seconds; the system will be temporarily unavailable",
|
||||||
|
"usage3": "The page will refresh automatically after a successful update",
|
||||||
|
"usage4": "If the update fails, the system will roll back to the current version",
|
||||||
|
"versionTooltipNew": "New version available, click to go to System Update",
|
||||||
|
"versionTooltipLatest": "You are on the latest version",
|
||||||
|
"prerelease": "Pre-release"
|
||||||
|
},
|
||||||
"builderApiKey": {
|
"builderApiKey": {
|
||||||
"title": "Builder API Key Configuration",
|
"title": "Builder API Key Configuration",
|
||||||
"alertTitle": "What is Builder API Key?",
|
"alertTitle": "What is Builder API Key?",
|
||||||
@@ -578,7 +662,8 @@
|
|||||||
"deleteSuccess": "User deleted successfully",
|
"deleteSuccess": "User deleted successfully",
|
||||||
"deleteFailed": "Failed to delete user",
|
"deleteFailed": "Failed to delete user",
|
||||||
"deleteConfirm": "Are you sure you want to delete this user?",
|
"deleteConfirm": "Are you sure you want to delete this user?",
|
||||||
"total": "Total {{total}} items"
|
"total": "Total {{total}} items",
|
||||||
|
"noData": "No user data"
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"title": "Statistics",
|
"title": "Statistics",
|
||||||
@@ -1039,7 +1124,9 @@
|
|||||||
"updateStatusFailed": "Failed to update copy trading status",
|
"updateStatusFailed": "Failed to update copy trading status",
|
||||||
"deleteSuccess": "Copy trading deleted successfully",
|
"deleteSuccess": "Copy trading deleted successfully",
|
||||||
"deleteFailed": "Failed to delete copy trading",
|
"deleteFailed": "Failed to delete copy trading",
|
||||||
"deleteConfirm": "Are you sure you want to delete this copy trading relationship?"
|
"deleteConfirm": "Are you sure you want to delete this copy trading relationship?",
|
||||||
|
"profitRate": "Profit Rate",
|
||||||
|
"noData": "No copy trading configuration"
|
||||||
},
|
},
|
||||||
"notificationSettings": {
|
"notificationSettings": {
|
||||||
"title": "Notification Settings",
|
"title": "Notification Settings",
|
||||||
@@ -1080,7 +1167,95 @@
|
|||||||
"getChatIdsFailed": "Failed to get Chat IDs",
|
"getChatIdsFailed": "Failed to get Chat IDs",
|
||||||
"getChatIdsNoToken": "Please enter Bot Token first",
|
"getChatIdsNoToken": "Please enter Bot Token first",
|
||||||
"getChatIdsNoMessage": "Chat ID not found, please send a message to the bot first (e.g., /start), then retry",
|
"getChatIdsNoMessage": "Chat ID not found, please send a message to the bot first (e.g., /start), then retry",
|
||||||
"getChatIdsButton": "Get Chat ID"
|
"getChatIdsButton": "Get Chat ID",
|
||||||
|
"botConfig": "Bot Configuration",
|
||||||
|
"templateConfig": "Template Configuration",
|
||||||
|
"templates": {
|
||||||
|
"title": "Message Template Configuration",
|
||||||
|
"templateType": "Template Type",
|
||||||
|
"templateContent": "Template Content",
|
||||||
|
"isDefault": "Default Template",
|
||||||
|
"isCustom": "Custom Template",
|
||||||
|
"resetToDefault": "Reset to Default",
|
||||||
|
"resetConfirm": "Are you sure you want to reset to default? Your custom content will be lost.",
|
||||||
|
"resetSuccess": "Reset successfully",
|
||||||
|
"resetFailed": "Reset failed",
|
||||||
|
"saveSuccess": "Saved successfully",
|
||||||
|
"saveFailed": "Save failed",
|
||||||
|
"testSuccess": "Test message sent successfully, please check Telegram",
|
||||||
|
"testFailed": "Failed to send test message",
|
||||||
|
"variables": "Available Variables",
|
||||||
|
"clickToCopy": "Click to copy",
|
||||||
|
"copied": "Copied",
|
||||||
|
"commonVariables": "Common Variables",
|
||||||
|
"orderVariables": "Order Variables",
|
||||||
|
"copyTradingVariables": "Copy Trading Variables",
|
||||||
|
"redeemVariables": "Redeem Variables",
|
||||||
|
"errorVariables": "Error Variables",
|
||||||
|
"filterVariables": "Filter Variables",
|
||||||
|
"strategyVariables": "Strategy Variables",
|
||||||
|
"contentPlaceholder": "Enter template content here, use {{variable}} to insert dynamic content",
|
||||||
|
"variableLabels": {
|
||||||
|
"account_name": "Account Name",
|
||||||
|
"wallet_address": "Wallet Address",
|
||||||
|
"time": "Time",
|
||||||
|
"order_id": "Order ID",
|
||||||
|
"market_title": "Market Title",
|
||||||
|
"market_link": "Market Link",
|
||||||
|
"side": "Side",
|
||||||
|
"outcome": "Outcome",
|
||||||
|
"price": "Price",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"amount": "Amount",
|
||||||
|
"available_balance": "Available Balance",
|
||||||
|
"leader_name": "Leader Name",
|
||||||
|
"config_name": "Copy Trading Config Name",
|
||||||
|
"error_message": "Error Message",
|
||||||
|
"filter_type": "Filter Type",
|
||||||
|
"filter_reason": "Filter Reason",
|
||||||
|
"strategy_name": "Strategy Name",
|
||||||
|
"transaction_hash": "Transaction Hash",
|
||||||
|
"total_value": "Total Redeem Value"
|
||||||
|
},
|
||||||
|
"variableDescriptions": {
|
||||||
|
"account_name": "Account name executing the order",
|
||||||
|
"wallet_address": "Wallet address (masked)",
|
||||||
|
"time": "Notification send time",
|
||||||
|
"order_id": "Unique order identifier",
|
||||||
|
"market_title": "Market/Event name",
|
||||||
|
"market_link": "Polymarket market link",
|
||||||
|
"side": "Order direction (Buy/Sell)",
|
||||||
|
"outcome": "Market outcome (YES/NO, etc.)",
|
||||||
|
"price": "Order price",
|
||||||
|
"quantity": "Order quantity (shares)",
|
||||||
|
"amount": "Order amount (USDC)",
|
||||||
|
"available_balance": "Account available balance (USDC)",
|
||||||
|
"leader_name": "Leader name/alias being copied",
|
||||||
|
"config_name": "Copy trading configuration name",
|
||||||
|
"error_message": "Order failure reason",
|
||||||
|
"filter_type": "Type of order filter",
|
||||||
|
"filter_reason": "Detailed reason for order filtering",
|
||||||
|
"strategy_name": "Crypto spread strategy name",
|
||||||
|
"transaction_hash": "Redeem transaction hash",
|
||||||
|
"total_value": "Total redeem value (USDC)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templateTypes": {
|
||||||
|
"ORDER_SUCCESS": "Order Success",
|
||||||
|
"ORDER_FAILED": "Order Failed",
|
||||||
|
"ORDER_FILTERED": "Order Filtered",
|
||||||
|
"CRYPTO_TAIL_SUCCESS": "Crypto Spread Strategy Success",
|
||||||
|
"REDEEM_SUCCESS": "Position Redeem Success",
|
||||||
|
"REDEEM_NO_RETURN": "Position Settled (No Return)"
|
||||||
|
},
|
||||||
|
"templateTypeDescriptions": {
|
||||||
|
"ORDER_SUCCESS": "Notification sent when order is successfully created",
|
||||||
|
"ORDER_FAILED": "Notification sent when order creation fails",
|
||||||
|
"ORDER_FILTERED": "Notification sent when order is filtered by risk control",
|
||||||
|
"CRYPTO_TAIL_SUCCESS": "Notification sent when crypto spread strategy order succeeds",
|
||||||
|
"REDEEM_SUCCESS": "Notification sent when position is successfully redeemed",
|
||||||
|
"REDEEM_NO_RETURN": "Notification sent when position is settled with no return"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"telegramConfig": {
|
"telegramConfig": {
|
||||||
"title": "Telegram Configuration Guide",
|
"title": "Telegram Configuration Guide",
|
||||||
@@ -1269,6 +1444,8 @@
|
|||||||
"title": "Backtest",
|
"title": "Backtest",
|
||||||
"taskName": "Task Name",
|
"taskName": "Task Name",
|
||||||
"leader": "Leader",
|
"leader": "Leader",
|
||||||
|
"balance": "Balance (Init→Final)",
|
||||||
|
"profit": "Profit (Amount/Rate)",
|
||||||
"initialBalance": "Initial Balance",
|
"initialBalance": "Initial Balance",
|
||||||
"backtestDays": "Backtest Days",
|
"backtestDays": "Backtest Days",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -1317,6 +1494,7 @@
|
|||||||
"createCopyTradingSuccess": "Copy trading config created successfully",
|
"createCopyTradingSuccess": "Copy trading config created successfully",
|
||||||
"noTasks": "No backtest tasks",
|
"noTasks": "No backtest tasks",
|
||||||
"noTrades": "No trade records",
|
"noTrades": "No trade records",
|
||||||
|
"noData": "No backtest data",
|
||||||
"fetchTasksFailed": "Failed to fetch task list",
|
"fetchTasksFailed": "Failed to fetch task list",
|
||||||
"fetchTaskDetailFailed": "Failed to fetch task detail",
|
"fetchTaskDetailFailed": "Failed to fetch task detail",
|
||||||
"fetchTradesFailed": "Failed to fetch trade records",
|
"fetchTradesFailed": "Failed to fetch trade records",
|
||||||
@@ -1404,17 +1582,19 @@
|
|||||||
"cryptoTailStrategy": {
|
"cryptoTailStrategy": {
|
||||||
"binanceApiAlert": {
|
"binanceApiAlert": {
|
||||||
"title": "Cannot connect to Binance API — strategy cannot run for now",
|
"title": "Cannot connect to Binance API — strategy cannot run for now",
|
||||||
"description": "Tail strategy needs Binance market data to work. The connection failed; this may be a network issue or Binance outage. Try again later by clicking the button below.",
|
"description": "Crypto spread strategy needs Binance market data to work. The connection failed; this may be a network issue or Binance outage. Try again later by clicking the button below.",
|
||||||
"recheck": "Re-check"
|
"recheck": "Re-check"
|
||||||
},
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"title": "Crypto Tail Strategy",
|
"title": "Crypto Spread Strategy",
|
||||||
"walletTip": "Use a dedicated wallet for tail strategy. Do not use it for manual trading or copy trading to avoid balance/position issues.",
|
"walletTip": "Use a dedicated wallet for crypto spread strategy. Do not use it for manual trading or copy trading to avoid balance/position issues.",
|
||||||
"addStrategy": "Add Strategy",
|
"addStrategy": "Add Strategy",
|
||||||
"strategyName": "Strategy Name",
|
"strategyName": "Strategy Name",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"market": "Market",
|
"market": "Market",
|
||||||
|
"marketAndTime": "Market / Time",
|
||||||
"timeWindow": "Time Window",
|
"timeWindow": "Time Window",
|
||||||
|
"config": "Config",
|
||||||
"priceRange": "Price Range",
|
"priceRange": "Price Range",
|
||||||
"amountMode": "Amount Mode",
|
"amountMode": "Amount Mode",
|
||||||
"ratio": "Ratio",
|
"ratio": "Ratio",
|
||||||
@@ -1427,8 +1607,10 @@
|
|||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"viewTriggers": "Orders",
|
"viewTriggers": "Orders",
|
||||||
|
"viewPnlCurve": "PnL Curve",
|
||||||
"deleteConfirm": "Delete this strategy?",
|
"deleteConfirm": "Delete this strategy?",
|
||||||
"fetchFailed": "Failed to fetch list"
|
"fetchFailed": "Failed to fetch list",
|
||||||
|
"configGuide": "Configuration Guide"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"walletTip": "Use a dedicated wallet to avoid balance or order issues.",
|
"walletTip": "Use a dedicated wallet to avoid balance or order issues.",
|
||||||
@@ -1451,17 +1633,21 @@
|
|||||||
"update": "Update",
|
"update": "Update",
|
||||||
"timeWindowStartLEEnd": "Window start must not be greater than end",
|
"timeWindowStartLEEnd": "Window start must not be greater than end",
|
||||||
"timeWindowExceed": "Time window must not exceed period length",
|
"timeWindowExceed": "Time window must not exceed period length",
|
||||||
"minSpreadMode": "Min spread",
|
"spreadMode": "Spread",
|
||||||
"minSpreadModeTip": "Whether to place an order is based on the spread between open and close in the current period. Auto: system computes a suggested spread from the last 20 klines (updated each period); Fixed: you enter a value (e.g. 30), order only when spread ≥ that value; None: no spread check, order when price is in range.",
|
"spreadModeTip": "Whether to place an order is based on the spread between open and close in the current period. Auto: system computes a suggested spread from the last 20 klines (updated each period); Fixed: you enter a value (e.g. 30); None: no spread check, order when price is in range.",
|
||||||
"minSpreadModeNone": "None",
|
"spreadModeNone": "None",
|
||||||
"minSpreadModeFixed": "Fixed",
|
"spreadModeFixed": "Fixed",
|
||||||
"minSpreadModeAuto": "Auto",
|
"spreadModeAuto": "Auto",
|
||||||
"minSpreadValue": "Min spread value (USDC)",
|
"spreadValue": "Spread value (USDC)",
|
||||||
"minSpreadValuePlaceholder": "e.g. 30"
|
"spreadValuePlaceholder": "e.g. 30",
|
||||||
|
"spreadDirection": "Spread Direction",
|
||||||
|
"spreadDirectionTip": "Min spread: trigger when spread ≥ configured value, buy price fixed at 0.99; Max spread: trigger when spread ≤ configured value, buy price = trigger price + 0.02 (suitable for low-price buying).",
|
||||||
|
"spreadDirectionMin": "Min Spread",
|
||||||
|
"spreadDirectionMax": "Max Spread"
|
||||||
},
|
},
|
||||||
"redeemRequiredModal": {
|
"redeemRequiredModal": {
|
||||||
"title": "Configure Auto Redeem First",
|
"title": "Configure Auto Redeem First",
|
||||||
"description": "Tail strategy requires auto redeem. Please configure Builder API Key and enable auto redeem in System Settings.",
|
"description": "Crypto spread strategy requires auto redeem. Please configure Builder API Key and enable auto redeem in System Settings.",
|
||||||
"goToSettings": "Go to Settings",
|
"goToSettings": "Go to Settings",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
},
|
},
|
||||||
@@ -1489,6 +1675,131 @@
|
|||||||
"emptySuccess": "No success records",
|
"emptySuccess": "No success records",
|
||||||
"emptyFail": "No failed records",
|
"emptyFail": "No failed records",
|
||||||
"totalCount": "{count} record(s)"
|
"totalCount": "{count} record(s)"
|
||||||
|
},
|
||||||
|
"pnlCurve": {
|
||||||
|
"title": "PnL Curve",
|
||||||
|
"totalPnl": "Total PnL",
|
||||||
|
"settledCount": "Settled",
|
||||||
|
"winRate": "Win Rate",
|
||||||
|
"maxDrawdown": "Max Drawdown",
|
||||||
|
"timeRange": "Time Range",
|
||||||
|
"today": "Today",
|
||||||
|
"last7Days": "Last 7 Days",
|
||||||
|
"last30Days": "Last 30 Days",
|
||||||
|
"all": "All",
|
||||||
|
"customRange": "Custom",
|
||||||
|
"empty": "No settled orders yet, cannot show PnL curve"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cryptoTailMonitor": {
|
||||||
|
"title": "Crypto Spread Strategy Monitor",
|
||||||
|
"selectStrategy": "Strategy",
|
||||||
|
"selectStrategyPlaceholder": "Select a strategy to monitor",
|
||||||
|
"direction": "Direction",
|
||||||
|
"directionUp": "Up",
|
||||||
|
"directionDown": "Down",
|
||||||
|
"noData": "Select a strategy to start monitoring",
|
||||||
|
"priceRange": "Price Range",
|
||||||
|
"timeWindow": "Time Window",
|
||||||
|
"stat": {
|
||||||
|
"openPrice": "Open Price",
|
||||||
|
"currentPrice": "Current Price",
|
||||||
|
"spread": "Spread",
|
||||||
|
"remainingTime": "Remaining",
|
||||||
|
"configuredSpread": "Configured Spread",
|
||||||
|
"configuredSpreadMin": "Min Spread",
|
||||||
|
"configuredSpreadMax": "Max Spread",
|
||||||
|
"status": "Status",
|
||||||
|
"minSpreadLine": "Min Spread",
|
||||||
|
"periodSpreadMinMax": "Period Spread",
|
||||||
|
"periodSpreadMin": "Period Min Spread",
|
||||||
|
"periodSpreadMax": "Period Max Spread",
|
||||||
|
"minSpread": "Min",
|
||||||
|
"maxSpread": "Max"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"triggered": "Triggered",
|
||||||
|
"periodEnded": "Period Ended",
|
||||||
|
"inCondition": "In Condition",
|
||||||
|
"waiting": "Waiting"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"title": "Price Chart",
|
||||||
|
"btcTitle": "BTC Price Chart",
|
||||||
|
"marketTitle": "Market Price Chart",
|
||||||
|
"priceChart": "Price Chart",
|
||||||
|
"price": "Price",
|
||||||
|
"openPrice": "Open",
|
||||||
|
"spread": "Spread",
|
||||||
|
"minSpreadLine": "Min Spread Line",
|
||||||
|
"maxSpreadLine": "Max Spread Line",
|
||||||
|
"marketUp": "Up",
|
||||||
|
"marketDown": "Down",
|
||||||
|
"time": "Time",
|
||||||
|
"latestPrice": "Latest",
|
||||||
|
"timeWindowStart": "Window Start",
|
||||||
|
"timeWindowEnd": "Window End"
|
||||||
|
},
|
||||||
|
"strategyInfo": {
|
||||||
|
"title": "Strategy Info",
|
||||||
|
"market": "Market",
|
||||||
|
"interval": "Interval",
|
||||||
|
"account": "Account",
|
||||||
|
"spreadMode": "Spread Mode",
|
||||||
|
"spreadDirection": "Spread Direction"
|
||||||
|
},
|
||||||
|
"periodSwitch": {
|
||||||
|
"mode": "Period Switch Mode",
|
||||||
|
"auto": "Auto",
|
||||||
|
"manual": "Manual",
|
||||||
|
"autoDesc": "Automatically switch to the latest period when it ends",
|
||||||
|
"manualDesc": "Keep complete data when period ends",
|
||||||
|
"switchToLatest": "Switch to Latest Period",
|
||||||
|
"periodEnded": "Current period has ended",
|
||||||
|
"newPeriodAvailable": "New period has started"
|
||||||
|
},
|
||||||
|
"manualOrder": {
|
||||||
|
"title": "Manual Order",
|
||||||
|
"buttonUp": "Buy Up",
|
||||||
|
"buttonDown": "Buy Down",
|
||||||
|
"confirmTitle": "Manual Order Confirmation",
|
||||||
|
"marketTitle": "Market Title",
|
||||||
|
"direction": "Direction",
|
||||||
|
"directionUp": "Up",
|
||||||
|
"directionDown": "Down",
|
||||||
|
"orderPrice": "Order Price",
|
||||||
|
"orderSize": "Order Size",
|
||||||
|
"totalAmount": "Total Amount",
|
||||||
|
"account": "Account",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm Order",
|
||||||
|
"orderUnit": "USDC",
|
||||||
|
"sizeUnit": "shares",
|
||||||
|
"statusNotOrdered": "Not Ordered",
|
||||||
|
"statusOrdered": "This period has been ordered",
|
||||||
|
"statusOrderedAuto": "This period has been auto-ordered",
|
||||||
|
"statusOrderedManual": "This period has been manually ordered",
|
||||||
|
"errorInsufficientBalance": "Insufficient account balance, available: {balance} USDC",
|
||||||
|
"errorPriceOutOfRange": "Price must be between 0 and 1",
|
||||||
|
"errorMinSize": "Size cannot be less than 1 share",
|
||||||
|
"errorExceedsBalance": "Total amount exceeds available balance",
|
||||||
|
"errorPriceRequired": "Please enter order price",
|
||||||
|
"errorSizeRequired": "Please enter order size",
|
||||||
|
"success": "Manual order successful",
|
||||||
|
"failed": "Manual order failed: {{reason}}",
|
||||||
|
"errorSigning": "Signing failed: {reason}",
|
||||||
|
"errorNetwork": "Network error, please try again later",
|
||||||
|
"priceNotLoaded": "Price data not loaded",
|
||||||
|
"insufficientBalance": "Insufficient balance, available amount less than 1 USDC",
|
||||||
|
"fetchBalanceFailed": "Failed to fetch account balance",
|
||||||
|
"availableBalance": "Available Balance",
|
||||||
|
"fetchLatestPrice": "Latest Price",
|
||||||
|
"priceUpdated": "Price updated",
|
||||||
|
"maxSize": "Max",
|
||||||
|
"invalidPriceOrBalance": "Invalid price or balance",
|
||||||
|
"insufficientBalanceForMax": "Insufficient balance for max size",
|
||||||
|
"maxSizeUpdated": "Updated to max size",
|
||||||
|
"periodChanged": "Period has changed, popup closed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "确定",
|
"confirm": "确定",
|
||||||
|
"later": "稍后",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"viewDetail": "查看详情",
|
"viewDetail": "查看详情",
|
||||||
@@ -41,7 +42,8 @@
|
|||||||
"copyFailed": "复制失败",
|
"copyFailed": "复制失败",
|
||||||
"ascending": "升序",
|
"ascending": "升序",
|
||||||
"descending": "降序",
|
"descending": "降序",
|
||||||
"day": "天"
|
"day": "天",
|
||||||
|
"orders": "单"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "登录",
|
"title": "登录",
|
||||||
@@ -91,6 +93,7 @@
|
|||||||
"accountNamePlaceholder": "账户名称(可选)",
|
"accountNamePlaceholder": "账户名称(可选)",
|
||||||
"accountIdRequired": "账户ID不能为空",
|
"accountIdRequired": "账户ID不能为空",
|
||||||
"walletAddress": "钱包地址",
|
"walletAddress": "钱包地址",
|
||||||
|
"walletType": "钱包类型",
|
||||||
"proxyAddress": "代理钱包地址",
|
"proxyAddress": "代理钱包地址",
|
||||||
"apiCredentials": "API 凭证",
|
"apiCredentials": "API 凭证",
|
||||||
"apiKey": "API Key",
|
"apiKey": "API Key",
|
||||||
@@ -174,7 +177,8 @@
|
|||||||
"updateFailed": "更新账户失败",
|
"updateFailed": "更新账户失败",
|
||||||
"getDetailFailedForEdit": "获取账户详情失败",
|
"getDetailFailedForEdit": "获取账户详情失败",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"fetchFailed": "获取账户列表失败"
|
"fetchFailed": "获取账户列表失败",
|
||||||
|
"noData": "暂无账户数据"
|
||||||
},
|
},
|
||||||
"accountImport": {
|
"accountImport": {
|
||||||
"title": "导入账户",
|
"title": "导入账户",
|
||||||
@@ -236,6 +240,51 @@
|
|||||||
"select": "选择此代理地址"
|
"select": "选择此代理地址"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"accountSetup": {
|
||||||
|
"title": "账户设置检查",
|
||||||
|
"completed": "已完成",
|
||||||
|
"pending": "待完成",
|
||||||
|
"refresh": "刷新状态",
|
||||||
|
"allCompleted": {
|
||||||
|
"title": "所有设置已完成",
|
||||||
|
"description": "您的账户已准备就绪,可以开始使用所有功能。"
|
||||||
|
},
|
||||||
|
"incomplete": {
|
||||||
|
"title": "账户设置未完成",
|
||||||
|
"description": "请完成以下设置步骤,以确保账户可以正常使用。"
|
||||||
|
},
|
||||||
|
"step1": {
|
||||||
|
"title": "部署代理钱包",
|
||||||
|
"description": "代理钱包是您在 Polymarket 上进行交易的必要组件。Safe 账户可点击下方按钮由系统一键部署;Magic 账户将跳转至 Polymarket 完成。",
|
||||||
|
"action": "部署代理钱包"
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"title": "启用交易",
|
||||||
|
"description": "需要配置 API 凭证才能进行交易。点击下方按钮由系统自动获取并保存 API Key。",
|
||||||
|
"action": "一键启用交易"
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
|
"title": "批准代币",
|
||||||
|
"description": "需要授权代理钱包使用您的 USDC 代币。点击下方按钮由系统自动完成代币授权。",
|
||||||
|
"action": "一键批准代币"
|
||||||
|
},
|
||||||
|
"approvalDetails": {
|
||||||
|
"title": "代币授权详情",
|
||||||
|
"CTF_CONTRACT": "CTF 合约",
|
||||||
|
"CTF_EXCHANGE": "CTF 交易所",
|
||||||
|
"NEG_RISK_EXCHANGE": "负风险交易所",
|
||||||
|
"NEG_RISK_ADAPTER": "负风险适配器",
|
||||||
|
"notApproved": "未授权",
|
||||||
|
"unlimited": "无限"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "检查失败",
|
||||||
|
"description": "无法检查账户设置状态,请稍后重试。"
|
||||||
|
},
|
||||||
|
"actionSuccess": "操作成功",
|
||||||
|
"actionFailed": "操作失败,请稍后重试",
|
||||||
|
"help": "提示:完成设置后,点击「刷新状态」按钮更新检查结果。如果所有步骤都已完成,您可以正常使用账户功能。"
|
||||||
|
},
|
||||||
"leader": {
|
"leader": {
|
||||||
"title": "Leader 管理",
|
"title": "Leader 管理",
|
||||||
"leaderName": "Leader 名称",
|
"leaderName": "Leader 名称",
|
||||||
@@ -265,7 +314,9 @@
|
|||||||
"leaders": "Leader 管理",
|
"leaders": "Leader 管理",
|
||||||
"templates": "跟单模板",
|
"templates": "跟单模板",
|
||||||
"copyTradingConfig": "跟单配置",
|
"copyTradingConfig": "跟单配置",
|
||||||
"cryptoTailStrategy": "尾盘策略",
|
"cryptoSpreadStrategy": "加密价差策略",
|
||||||
|
"cryptoTailStrategy": "策略配置",
|
||||||
|
"cryptoTailMonitor": "实时监控",
|
||||||
"positions": "仓位管理",
|
"positions": "仓位管理",
|
||||||
"backtest": "回测",
|
"backtest": "回测",
|
||||||
"statistics": "统计信息",
|
"statistics": "统计信息",
|
||||||
@@ -371,6 +422,40 @@
|
|||||||
"saveFailed": "保存自动赎回配置失败"
|
"saveFailed": "保存自动赎回配置失败"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"systemUpdate": {
|
||||||
|
"title": "系统更新",
|
||||||
|
"currentVersion": "当前版本",
|
||||||
|
"ready": "就绪",
|
||||||
|
"hasNewVersion": "发现新版本: {{version}}",
|
||||||
|
"alreadyLatest": "当前已是最新版本",
|
||||||
|
"checkFailed": "检查更新失败",
|
||||||
|
"confirmTitle": "确认更新",
|
||||||
|
"confirmContent1": "确定要更新到版本 {{version}} 吗?",
|
||||||
|
"confirmContent2": "更新过程中系统将暂时不可用(约30-60秒)。",
|
||||||
|
"confirmContent3": "更新完成后页面将自动刷新。",
|
||||||
|
"okText": "立即更新",
|
||||||
|
"cancelText": "取消",
|
||||||
|
"updateStarted": "更新已启动,请稍候...",
|
||||||
|
"updateFailedWithMessage": "更新失败: {{message}}",
|
||||||
|
"updateSuccessRefresh": "更新成功!页面将在3秒后刷新...",
|
||||||
|
"needAdmin": "需要管理员权限才能执行更新",
|
||||||
|
"startFailed": "启动更新失败",
|
||||||
|
"updating": "系统正在更新",
|
||||||
|
"updateFailedTitle": "更新失败",
|
||||||
|
"checkUpdate": "检查更新",
|
||||||
|
"newVersionFound": "发现新版本",
|
||||||
|
"publishedAt": "发布时间",
|
||||||
|
"releaseNotes": "更新内容",
|
||||||
|
"upgradeNow": "立即升级到 v{{version}}",
|
||||||
|
"usageTitle": "使用说明",
|
||||||
|
"usage1": "点击「检查更新」按钮检查是否有新版本",
|
||||||
|
"usage2": "更新过程约需30-60秒,期间系统将暂时不可用",
|
||||||
|
"usage3": "更新成功后页面将自动刷新",
|
||||||
|
"usage4": "如果更新失败,系统会自动回滚到当前版本",
|
||||||
|
"versionTooltipNew": "有新版本可用,点击前往系统更新",
|
||||||
|
"versionTooltipLatest": "当前已是最新版本",
|
||||||
|
"prerelease": "预发布"
|
||||||
|
},
|
||||||
"builderApiKey": {
|
"builderApiKey": {
|
||||||
"title": "Builder API Key 配置",
|
"title": "Builder API Key 配置",
|
||||||
"alertTitle": "什么是 Builder API Key?",
|
"alertTitle": "什么是 Builder API Key?",
|
||||||
@@ -577,7 +662,8 @@
|
|||||||
"deleteSuccess": "删除用户成功",
|
"deleteSuccess": "删除用户成功",
|
||||||
"deleteFailed": "删除用户失败",
|
"deleteFailed": "删除用户失败",
|
||||||
"deleteConfirm": "确定要删除这个用户吗?",
|
"deleteConfirm": "确定要删除这个用户吗?",
|
||||||
"total": "共 {{total}} 条"
|
"total": "共 {{total}} 条",
|
||||||
|
"noData": "暂无用户数据"
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"title": "统计信息",
|
"title": "统计信息",
|
||||||
@@ -1038,7 +1124,9 @@
|
|||||||
"updateStatusFailed": "更新跟单状态失败",
|
"updateStatusFailed": "更新跟单状态失败",
|
||||||
"deleteSuccess": "删除跟单成功",
|
"deleteSuccess": "删除跟单成功",
|
||||||
"deleteFailed": "删除跟单失败",
|
"deleteFailed": "删除跟单失败",
|
||||||
"deleteConfirm": "确定要删除这个跟单关系吗?"
|
"deleteConfirm": "确定要删除这个跟单关系吗?",
|
||||||
|
"profitRate": "收益率",
|
||||||
|
"noData": "暂无跟单配置"
|
||||||
},
|
},
|
||||||
"notificationSettings": {
|
"notificationSettings": {
|
||||||
"title": "消息推送设置",
|
"title": "消息推送设置",
|
||||||
@@ -1079,7 +1167,95 @@
|
|||||||
"getChatIdsFailed": "获取 Chat IDs 失败",
|
"getChatIdsFailed": "获取 Chat IDs 失败",
|
||||||
"getChatIdsNoToken": "请先填写 Bot Token",
|
"getChatIdsNoToken": "请先填写 Bot Token",
|
||||||
"getChatIdsNoMessage": "未找到 Chat ID,请先向机器人发送一条消息(如 /start),然后重试",
|
"getChatIdsNoMessage": "未找到 Chat ID,请先向机器人发送一条消息(如 /start),然后重试",
|
||||||
"getChatIdsButton": "获取 Chat ID"
|
"getChatIdsButton": "获取 Chat ID",
|
||||||
|
"botConfig": "机器人配置",
|
||||||
|
"templateConfig": "模板配置",
|
||||||
|
"templates": {
|
||||||
|
"title": "消息模板配置",
|
||||||
|
"templateType": "模板类型",
|
||||||
|
"templateContent": "模板内容",
|
||||||
|
"isDefault": "默认模板",
|
||||||
|
"isCustom": "自定义模板",
|
||||||
|
"resetToDefault": "重置为默认",
|
||||||
|
"resetConfirm": "确定要重置为默认模板吗?您的自定义内容将丢失。",
|
||||||
|
"resetSuccess": "重置成功",
|
||||||
|
"resetFailed": "重置失败",
|
||||||
|
"saveSuccess": "保存成功",
|
||||||
|
"saveFailed": "保存失败",
|
||||||
|
"testSuccess": "测试消息发送成功,请检查 Telegram",
|
||||||
|
"testFailed": "测试消息发送失败",
|
||||||
|
"variables": "可用变量",
|
||||||
|
"clickToCopy": "点击复制",
|
||||||
|
"copied": "已复制",
|
||||||
|
"commonVariables": "通用变量",
|
||||||
|
"orderVariables": "订单变量",
|
||||||
|
"copyTradingVariables": "跟单变量",
|
||||||
|
"redeemVariables": "赎回变量",
|
||||||
|
"errorVariables": "错误变量",
|
||||||
|
"filterVariables": "过滤变量",
|
||||||
|
"strategyVariables": "策略变量",
|
||||||
|
"contentPlaceholder": "在此输入模板内容,使用 {{变量名}} 插入动态内容",
|
||||||
|
"variableLabels": {
|
||||||
|
"account_name": "账户名称",
|
||||||
|
"wallet_address": "钱包地址",
|
||||||
|
"time": "时间",
|
||||||
|
"order_id": "订单ID",
|
||||||
|
"market_title": "市场标题",
|
||||||
|
"market_link": "市场链接",
|
||||||
|
"side": "方向",
|
||||||
|
"outcome": "市场方向",
|
||||||
|
"price": "价格",
|
||||||
|
"quantity": "数量",
|
||||||
|
"amount": "金额",
|
||||||
|
"available_balance": "可用余额",
|
||||||
|
"leader_name": "Leader 名称",
|
||||||
|
"config_name": "跟单配置名",
|
||||||
|
"error_message": "错误信息",
|
||||||
|
"filter_type": "过滤类型",
|
||||||
|
"filter_reason": "过滤原因",
|
||||||
|
"strategy_name": "策略名称",
|
||||||
|
"transaction_hash": "交易哈希",
|
||||||
|
"total_value": "赎回总价值"
|
||||||
|
},
|
||||||
|
"variableDescriptions": {
|
||||||
|
"account_name": "执行订单的账户名称",
|
||||||
|
"wallet_address": "钱包地址(已脱敏)",
|
||||||
|
"time": "通知发送时间",
|
||||||
|
"order_id": "订单唯一标识",
|
||||||
|
"market_title": "市场/事件名称",
|
||||||
|
"market_link": "Polymarket 市场链接",
|
||||||
|
"side": "订单方向(买入/卖出)",
|
||||||
|
"outcome": "市场方向(YES/NO 等)",
|
||||||
|
"price": "订单价格",
|
||||||
|
"quantity": "订单数量(shares)",
|
||||||
|
"amount": "订单金额(USDC)",
|
||||||
|
"available_balance": "账户可用余额(USDC)",
|
||||||
|
"leader_name": "跟单的 Leader 名称/备注",
|
||||||
|
"config_name": "跟单配置名称",
|
||||||
|
"error_message": "订单失败原因",
|
||||||
|
"filter_type": "订单被过滤的类型",
|
||||||
|
"filter_reason": "订单被过滤的详细原因",
|
||||||
|
"strategy_name": "加密价差策略名称",
|
||||||
|
"transaction_hash": "赎回交易的哈希值",
|
||||||
|
"total_value": "赎回的总价值(USDC)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templateTypes": {
|
||||||
|
"ORDER_SUCCESS": "订单成功通知",
|
||||||
|
"ORDER_FAILED": "订单失败通知",
|
||||||
|
"ORDER_FILTERED": "订单过滤通知",
|
||||||
|
"CRYPTO_TAIL_SUCCESS": "加密价差策略成功通知",
|
||||||
|
"REDEEM_SUCCESS": "仓位赎回成功通知",
|
||||||
|
"REDEEM_NO_RETURN": "仓位结算(无收益)通知"
|
||||||
|
},
|
||||||
|
"templateTypeDescriptions": {
|
||||||
|
"ORDER_SUCCESS": "订单创建成功时发送的通知",
|
||||||
|
"ORDER_FAILED": "订单创建失败时发送的通知",
|
||||||
|
"ORDER_FILTERED": "订单被风控过滤时发送的通知",
|
||||||
|
"CRYPTO_TAIL_SUCCESS": "加密价差策略下单成功时发送的通知",
|
||||||
|
"REDEEM_SUCCESS": "仓位赎回成功时发送的通知",
|
||||||
|
"REDEEM_NO_RETURN": "仓位结算但无收益时发送的通知"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"telegramConfig": {
|
"telegramConfig": {
|
||||||
"title": "Telegram 配置说明",
|
"title": "Telegram 配置说明",
|
||||||
@@ -1268,6 +1444,8 @@
|
|||||||
"title": "回测",
|
"title": "回测",
|
||||||
"taskName": "任务名称",
|
"taskName": "任务名称",
|
||||||
"leader": "Leader",
|
"leader": "Leader",
|
||||||
|
"balance": "资金 (初始→最终)",
|
||||||
|
"profit": "收益 (金额/比例)",
|
||||||
"initialBalance": "初始资金",
|
"initialBalance": "初始资金",
|
||||||
"backtestDays": "回测天数",
|
"backtestDays": "回测天数",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
@@ -1316,6 +1494,7 @@
|
|||||||
"createCopyTradingSuccess": "跟单配置创建成功",
|
"createCopyTradingSuccess": "跟单配置创建成功",
|
||||||
"noTasks": "暂无回测任务",
|
"noTasks": "暂无回测任务",
|
||||||
"noTrades": "暂无交易记录",
|
"noTrades": "暂无交易记录",
|
||||||
|
"noData": "暂无回测数据",
|
||||||
"fetchTasksFailed": "获取任务列表失败",
|
"fetchTasksFailed": "获取任务列表失败",
|
||||||
"fetchTaskDetailFailed": "获取任务详情失败",
|
"fetchTaskDetailFailed": "获取任务详情失败",
|
||||||
"fetchTradesFailed": "获取交易记录失败",
|
"fetchTradesFailed": "获取交易记录失败",
|
||||||
@@ -1403,17 +1582,19 @@
|
|||||||
"cryptoTailStrategy": {
|
"cryptoTailStrategy": {
|
||||||
"binanceApiAlert": {
|
"binanceApiAlert": {
|
||||||
"title": "无法连接币安 API,策略暂时不能运行",
|
"title": "无法连接币安 API,策略暂时不能运行",
|
||||||
"description": "尾盘策略需要从币安获取行情数据才能工作。当前连接失败,可能是网络问题或币安服务异常,请稍后点击下方按钮重新检测。",
|
"description": "加密价差策略需要从币安获取行情数据才能工作。当前连接失败,可能是网络问题或币安服务异常,请稍后点击下方按钮重新检测。",
|
||||||
"recheck": "重新检测"
|
"recheck": "重新检测"
|
||||||
},
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"title": "加密尾盘策略",
|
"title": "加密价差策略",
|
||||||
"walletTip": "请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。",
|
"walletTip": "请使用单独的钱包运行加密价差策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。",
|
||||||
"addStrategy": "新增策略",
|
"addStrategy": "新增策略",
|
||||||
"strategyName": "策略名称",
|
"strategyName": "策略名称",
|
||||||
"account": "账户",
|
"account": "账户",
|
||||||
"market": "关联市场",
|
"market": "关联市场",
|
||||||
|
"marketAndTime": "市场/时间",
|
||||||
"timeWindow": "时间区间",
|
"timeWindow": "时间区间",
|
||||||
|
"config": "配置",
|
||||||
"priceRange": "价格区间",
|
"priceRange": "价格区间",
|
||||||
"amountMode": "投入方式",
|
"amountMode": "投入方式",
|
||||||
"ratio": "比例",
|
"ratio": "比例",
|
||||||
@@ -1426,8 +1607,10 @@
|
|||||||
"disable": "停用",
|
"disable": "停用",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"viewTriggers": "订单",
|
"viewTriggers": "订单",
|
||||||
|
"viewPnlCurve": "收益曲线",
|
||||||
"deleteConfirm": "确定删除该策略?",
|
"deleteConfirm": "确定删除该策略?",
|
||||||
"fetchFailed": "获取列表失败"
|
"fetchFailed": "获取列表失败",
|
||||||
|
"configGuide": "配置指南"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"walletTip": "建议使用专用钱包,避免手动操作等导致余额或下单异常。",
|
"walletTip": "建议使用专用钱包,避免手动操作等导致余额或下单异常。",
|
||||||
@@ -1450,17 +1633,21 @@
|
|||||||
"update": "更新",
|
"update": "更新",
|
||||||
"timeWindowStartLEEnd": "时间区间开始不能大于结束",
|
"timeWindowStartLEEnd": "时间区间开始不能大于结束",
|
||||||
"timeWindowExceed": "时间区间不能超过周期长度",
|
"timeWindowExceed": "时间区间不能超过周期长度",
|
||||||
"minSpreadMode": "最小价差",
|
"spreadMode": "价差",
|
||||||
"minSpreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 20 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30),仅当价差 ≥ 该值时才下单;无:不校验价差,满足价格区间即下单。",
|
"spreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 20 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30);无:不校验价差,满足价格区间即下单。",
|
||||||
"minSpreadModeNone": "无",
|
"spreadModeNone": "无",
|
||||||
"minSpreadModeFixed": "固定",
|
"spreadModeFixed": "固定",
|
||||||
"minSpreadModeAuto": "自动",
|
"spreadModeAuto": "自动",
|
||||||
"minSpreadValue": "最小价差数值 (USDC)",
|
"spreadValue": "价差数值 (USDC)",
|
||||||
"minSpreadValuePlaceholder": "如 30"
|
"spreadValuePlaceholder": "如 30",
|
||||||
|
"spreadDirection": "价差方向",
|
||||||
|
"spreadDirectionTip": "最小价差:价差 ≥ 配置值时触发,买入价固定 0.99;最大价差:价差 ≤ 配置值时触发,买入价 = 触发价 + 0.02(适合低价买入)。",
|
||||||
|
"spreadDirectionMin": "最小价差",
|
||||||
|
"spreadDirectionMax": "最大价差"
|
||||||
},
|
},
|
||||||
"redeemRequiredModal": {
|
"redeemRequiredModal": {
|
||||||
"title": "请先配置自动赎回",
|
"title": "请先配置自动赎回",
|
||||||
"description": "尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。",
|
"description": "加密价差策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。",
|
||||||
"goToSettings": "去配置",
|
"goToSettings": "去配置",
|
||||||
"cancel": "取消"
|
"cancel": "取消"
|
||||||
},
|
},
|
||||||
@@ -1488,6 +1675,131 @@
|
|||||||
"emptySuccess": "暂无成功记录",
|
"emptySuccess": "暂无成功记录",
|
||||||
"emptyFail": "暂无失败记录",
|
"emptyFail": "暂无失败记录",
|
||||||
"totalCount": "共 {count} 条"
|
"totalCount": "共 {count} 条"
|
||||||
|
},
|
||||||
|
"pnlCurve": {
|
||||||
|
"title": "收益曲线",
|
||||||
|
"totalPnl": "总收益",
|
||||||
|
"settledCount": "已结算笔数",
|
||||||
|
"winRate": "胜率",
|
||||||
|
"maxDrawdown": "最大回撤",
|
||||||
|
"timeRange": "时间范围",
|
||||||
|
"today": "今日",
|
||||||
|
"last7Days": "近7天",
|
||||||
|
"last30Days": "近30天",
|
||||||
|
"all": "全部",
|
||||||
|
"customRange": "自定义",
|
||||||
|
"empty": "暂无已结算订单,无法展示收益曲线"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cryptoTailMonitor": {
|
||||||
|
"title": "加密价差策略监控",
|
||||||
|
"selectStrategy": "选择策略",
|
||||||
|
"selectStrategyPlaceholder": "请选择要监控的策略",
|
||||||
|
"direction": "监控方向",
|
||||||
|
"directionUp": "Up",
|
||||||
|
"directionDown": "Down",
|
||||||
|
"noData": "请选择一个策略开始监控",
|
||||||
|
"priceRange": "价格区间",
|
||||||
|
"timeWindow": "时间窗口",
|
||||||
|
"stat": {
|
||||||
|
"openPrice": "开盘价",
|
||||||
|
"currentPrice": "最新价",
|
||||||
|
"spread": "价差",
|
||||||
|
"remainingTime": "剩余时间",
|
||||||
|
"configuredSpread": "配置价差",
|
||||||
|
"configuredSpreadMin": "最小价差",
|
||||||
|
"configuredSpreadMax": "最大价差",
|
||||||
|
"status": "状态",
|
||||||
|
"minSpreadLine": "最小价差线",
|
||||||
|
"periodSpreadMinMax": "周期内价差",
|
||||||
|
"periodSpreadMin": "周期内最小价差",
|
||||||
|
"periodSpreadMax": "周期内最大价差",
|
||||||
|
"minSpread": "最小",
|
||||||
|
"maxSpread": "最大"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"triggered": "已触发",
|
||||||
|
"periodEnded": "周期结束",
|
||||||
|
"inCondition": "满足条件",
|
||||||
|
"waiting": "等待中"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"title": "分时图",
|
||||||
|
"btcTitle": "BTC 分时图",
|
||||||
|
"marketTitle": "市场分时图",
|
||||||
|
"priceChart": "价格分时图",
|
||||||
|
"price": "价格",
|
||||||
|
"openPrice": "开盘价",
|
||||||
|
"spread": "价差",
|
||||||
|
"minSpreadLine": "最小价差线",
|
||||||
|
"maxSpreadLine": "最大价差线",
|
||||||
|
"marketUp": "Up",
|
||||||
|
"marketDown": "Down",
|
||||||
|
"time": "时间",
|
||||||
|
"latestPrice": "最新价",
|
||||||
|
"timeWindowStart": "区间开始",
|
||||||
|
"timeWindowEnd": "区间结束"
|
||||||
|
},
|
||||||
|
"strategyInfo": {
|
||||||
|
"title": "策略信息",
|
||||||
|
"market": "市场",
|
||||||
|
"interval": "周期",
|
||||||
|
"account": "账户",
|
||||||
|
"spreadMode": "价差模式",
|
||||||
|
"spreadDirection": "价差方向"
|
||||||
|
},
|
||||||
|
"periodSwitch": {
|
||||||
|
"mode": "周期切换模式",
|
||||||
|
"auto": "自动",
|
||||||
|
"manual": "手动",
|
||||||
|
"autoDesc": "周期结束时自动切换到最新周期",
|
||||||
|
"manualDesc": "周期结束时保留完整数据",
|
||||||
|
"switchToLatest": "切换到最新周期",
|
||||||
|
"periodEnded": "当前周期已结束",
|
||||||
|
"newPeriodAvailable": "新周期已开始"
|
||||||
|
},
|
||||||
|
"manualOrder": {
|
||||||
|
"title": "手动下单",
|
||||||
|
"buttonUp": "买入 Up",
|
||||||
|
"buttonDown": "买入 Down",
|
||||||
|
"confirmTitle": "手动下单确认",
|
||||||
|
"marketTitle": "市场标题",
|
||||||
|
"direction": "方向",
|
||||||
|
"directionUp": "Up",
|
||||||
|
"directionDown": "Down",
|
||||||
|
"orderPrice": "下单价格",
|
||||||
|
"orderSize": "下单数量",
|
||||||
|
"totalAmount": "总金额",
|
||||||
|
"account": "账户",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "确认下单",
|
||||||
|
"orderUnit": "USDC",
|
||||||
|
"sizeUnit": "张",
|
||||||
|
"statusNotOrdered": "未下单",
|
||||||
|
"statusOrdered": "本周期已下单",
|
||||||
|
"statusOrderedAuto": "本周期已自动下单",
|
||||||
|
"statusOrderedManual": "本周期已手动下单",
|
||||||
|
"errorInsufficientBalance": "账户余额不足,可用余额:{{balance}} USDC",
|
||||||
|
"errorPriceOutOfRange": "价格必须在 0~1 之间",
|
||||||
|
"errorMinSize": "数量不能少于 1 张",
|
||||||
|
"errorExceedsBalance": "总金额超过可用余额",
|
||||||
|
"errorPriceRequired": "请输入下单价格",
|
||||||
|
"errorSizeRequired": "请输入下单数量",
|
||||||
|
"success": "手动下单成功",
|
||||||
|
"failed": "手动下单失败:{{reason}}",
|
||||||
|
"errorSigning": "签名失败:{{reason}}",
|
||||||
|
"errorNetwork": "网络错误,请稍后重试",
|
||||||
|
"priceNotLoaded": "价格数据未加载",
|
||||||
|
"insufficientBalance": "余额不足,可用金额少于 1 USDC",
|
||||||
|
"fetchBalanceFailed": "获取账户余额失败",
|
||||||
|
"availableBalance": "可用余额",
|
||||||
|
"fetchLatestPrice": "获取最新价",
|
||||||
|
"priceUpdated": "价格已更新",
|
||||||
|
"maxSize": "最大",
|
||||||
|
"invalidPriceOrBalance": "价格或余额无效",
|
||||||
|
"insufficientBalanceForMax": "余额不足以购买最大数量",
|
||||||
|
"maxSizeUpdated": "已更新为最大数量",
|
||||||
|
"periodChanged": "周期已切换,弹窗已关闭"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"success": "成功",
|
"success": "成功",
|
||||||
"failed": "失敗",
|
"failed": "失敗",
|
||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
|
"later": "稍後",
|
||||||
"submit": "提交",
|
"submit": "提交",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"close": "關閉",
|
"close": "關閉",
|
||||||
@@ -41,7 +42,8 @@
|
|||||||
"pageOf": "第",
|
"pageOf": "第",
|
||||||
"ascending": "升序",
|
"ascending": "升序",
|
||||||
"descending": "降序",
|
"descending": "降序",
|
||||||
"day": "天"
|
"day": "天",
|
||||||
|
"orders": "單"
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"title": "賬戶管理",
|
"title": "賬戶管理",
|
||||||
@@ -175,7 +177,8 @@
|
|||||||
"updateFailed": "更新賬戶失敗",
|
"updateFailed": "更新賬戶失敗",
|
||||||
"getDetailFailedForEdit": "獲取賬戶詳情失敗",
|
"getDetailFailedForEdit": "獲取賬戶詳情失敗",
|
||||||
"loading": "加載中...",
|
"loading": "加載中...",
|
||||||
"fetchFailed": "獲取賬戶列表失敗"
|
"fetchFailed": "獲取賬戶列表失敗",
|
||||||
|
"noData": "暫無賬戶數據"
|
||||||
},
|
},
|
||||||
"accountImport": {
|
"accountImport": {
|
||||||
"title": "導入賬戶",
|
"title": "導入賬戶",
|
||||||
@@ -237,6 +240,51 @@
|
|||||||
"select": "選擇此代理地址"
|
"select": "選擇此代理地址"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"accountSetup": {
|
||||||
|
"title": "帳戶設置檢查",
|
||||||
|
"completed": "已完成",
|
||||||
|
"pending": "待完成",
|
||||||
|
"refresh": "刷新狀態",
|
||||||
|
"allCompleted": {
|
||||||
|
"title": "所有設置已完成",
|
||||||
|
"description": "您的帳戶已準備就緒,可以開始使用所有功能。"
|
||||||
|
},
|
||||||
|
"incomplete": {
|
||||||
|
"title": "帳戶設置未完成",
|
||||||
|
"description": "請完成以下設置步驟,以確保帳戶可以正常使用。"
|
||||||
|
},
|
||||||
|
"step1": {
|
||||||
|
"title": "部署代理錢包",
|
||||||
|
"description": "代理錢包是您在 Polymarket 上進行交易的必要組件。Safe 帳戶可點擊下方按鈕由系統一鍵部署;Magic 帳戶將跳轉至 Polymarket 完成。",
|
||||||
|
"action": "部署代理錢包"
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"title": "啟用交易",
|
||||||
|
"description": "需要配置 API 憑證才能進行交易。點擊下方按鈕由系統自動獲取並保存 API Key。",
|
||||||
|
"action": "一鍵啟用交易"
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
|
"title": "批准代幣",
|
||||||
|
"description": "需要授權代理錢包使用您的 USDC 代幣。點擊下方按鈕由系統自動完成代幣授權。",
|
||||||
|
"action": "一鍵批准代幣"
|
||||||
|
},
|
||||||
|
"approvalDetails": {
|
||||||
|
"title": "代幣授權詳情",
|
||||||
|
"CTF_CONTRACT": "CTF 合約",
|
||||||
|
"CTF_EXCHANGE": "CTF 交易所",
|
||||||
|
"NEG_RISK_EXCHANGE": "負風險交易所",
|
||||||
|
"NEG_RISK_ADAPTER": "負風險適配器",
|
||||||
|
"notApproved": "未授權",
|
||||||
|
"unlimited": "無限"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "檢查失敗",
|
||||||
|
"description": "無法檢查帳戶設置狀態,請稍後重試。"
|
||||||
|
},
|
||||||
|
"actionSuccess": "操作成功",
|
||||||
|
"actionFailed": "操作失敗,請稍後重試",
|
||||||
|
"help": "提示:完成設置後,點擊「刷新狀態」按鈕更新檢查結果。如果所有步驟都已完成,您可以正常使用帳戶功能。"
|
||||||
|
},
|
||||||
"leader": {
|
"leader": {
|
||||||
"title": "Leader 管理",
|
"title": "Leader 管理",
|
||||||
"leaderName": "Leader 名稱",
|
"leaderName": "Leader 名稱",
|
||||||
@@ -266,7 +314,9 @@
|
|||||||
"leaders": "Leader 管理",
|
"leaders": "Leader 管理",
|
||||||
"templates": "跟單模板",
|
"templates": "跟單模板",
|
||||||
"copyTradingConfig": "跟單配置",
|
"copyTradingConfig": "跟單配置",
|
||||||
"cryptoTailStrategy": "尾盤策略",
|
"cryptoSpreadStrategy": "加密價差策略",
|
||||||
|
"cryptoTailStrategy": "策略配置",
|
||||||
|
"cryptoTailMonitor": "即時監控",
|
||||||
"positions": "倉位管理",
|
"positions": "倉位管理",
|
||||||
"backtest": "回測",
|
"backtest": "回測",
|
||||||
"statistics": "統計信息",
|
"statistics": "統計信息",
|
||||||
@@ -372,6 +422,40 @@
|
|||||||
"saveFailed": "保存自動贖回配置失敗"
|
"saveFailed": "保存自動贖回配置失敗"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"systemUpdate": {
|
||||||
|
"title": "系統更新",
|
||||||
|
"currentVersion": "當前版本",
|
||||||
|
"ready": "就緒",
|
||||||
|
"hasNewVersion": "發現新版本: {{version}}",
|
||||||
|
"alreadyLatest": "當前已是最新版本",
|
||||||
|
"checkFailed": "檢查更新失敗",
|
||||||
|
"confirmTitle": "確認更新",
|
||||||
|
"confirmContent1": "確定要更新到版本 {{version}} 嗎?",
|
||||||
|
"confirmContent2": "更新過程中系統將暫時不可用(約30-60秒)。",
|
||||||
|
"confirmContent3": "更新完成後頁面將自動刷新。",
|
||||||
|
"okText": "立即更新",
|
||||||
|
"cancelText": "取消",
|
||||||
|
"updateStarted": "更新已啟動,請稍候...",
|
||||||
|
"updateFailedWithMessage": "更新失敗: {{message}}",
|
||||||
|
"updateSuccessRefresh": "更新成功!頁面將在3秒後刷新...",
|
||||||
|
"needAdmin": "需要管理員權限才能執行更新",
|
||||||
|
"startFailed": "啟動更新失敗",
|
||||||
|
"updating": "系統正在更新",
|
||||||
|
"updateFailedTitle": "更新失敗",
|
||||||
|
"checkUpdate": "檢查更新",
|
||||||
|
"newVersionFound": "發現新版本",
|
||||||
|
"publishedAt": "發佈時間",
|
||||||
|
"releaseNotes": "更新內容",
|
||||||
|
"upgradeNow": "立即升級到 v{{version}}",
|
||||||
|
"usageTitle": "使用說明",
|
||||||
|
"usage1": "點擊「檢查更新」按鈕檢查是否有新版本",
|
||||||
|
"usage2": "更新過程約需30-60秒,期間系統將暫時不可用",
|
||||||
|
"usage3": "更新成功後頁面將自動刷新",
|
||||||
|
"usage4": "如果更新失敗,系統會自動回滾到當前版本",
|
||||||
|
"versionTooltipNew": "有新版本可用,點擊前往系統更新",
|
||||||
|
"versionTooltipLatest": "當前已是最新版本",
|
||||||
|
"prerelease": "預發佈"
|
||||||
|
},
|
||||||
"builderApiKey": {
|
"builderApiKey": {
|
||||||
"title": "Builder API Key 配置",
|
"title": "Builder API Key 配置",
|
||||||
"alertTitle": "什麼是 Builder API Key?",
|
"alertTitle": "什麼是 Builder API Key?",
|
||||||
@@ -578,7 +662,8 @@
|
|||||||
"deleteSuccess": "刪除用戶成功",
|
"deleteSuccess": "刪除用戶成功",
|
||||||
"deleteFailed": "刪除用戶失敗",
|
"deleteFailed": "刪除用戶失敗",
|
||||||
"deleteConfirm": "確定要刪除這個用戶嗎?",
|
"deleteConfirm": "確定要刪除這個用戶嗎?",
|
||||||
"total": "共 {{total}} 條"
|
"total": "共 {{total}} 條",
|
||||||
|
"noData": "暫無用戶數據"
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"title": "統計信息",
|
"title": "統計信息",
|
||||||
@@ -1039,7 +1124,9 @@
|
|||||||
"updateStatusFailed": "更新跟單狀態失敗",
|
"updateStatusFailed": "更新跟單狀態失敗",
|
||||||
"deleteSuccess": "刪除跟單成功",
|
"deleteSuccess": "刪除跟單成功",
|
||||||
"deleteFailed": "刪除跟單失敗",
|
"deleteFailed": "刪除跟單失敗",
|
||||||
"deleteConfirm": "確定要刪除這個跟單關係嗎?"
|
"deleteConfirm": "確定要刪除這個跟單關係嗎?",
|
||||||
|
"profitRate": "收益率",
|
||||||
|
"noData": "暫無跟單配置"
|
||||||
},
|
},
|
||||||
"notificationSettings": {
|
"notificationSettings": {
|
||||||
"title": "消息推送設置",
|
"title": "消息推送設置",
|
||||||
@@ -1080,7 +1167,95 @@
|
|||||||
"getChatIdsFailed": "獲取 Chat IDs 失敗",
|
"getChatIdsFailed": "獲取 Chat IDs 失敗",
|
||||||
"getChatIdsNoToken": "請先填寫 Bot Token",
|
"getChatIdsNoToken": "請先填寫 Bot Token",
|
||||||
"getChatIdsNoMessage": "未找到 Chat ID,請先向機器人發送一條消息(如 /start),然後重試",
|
"getChatIdsNoMessage": "未找到 Chat ID,請先向機器人發送一條消息(如 /start),然後重試",
|
||||||
"getChatIdsButton": "獲取 Chat ID"
|
"getChatIdsButton": "獲取 Chat ID",
|
||||||
|
"botConfig": "機器人配置",
|
||||||
|
"templateConfig": "模板配置",
|
||||||
|
"templates": {
|
||||||
|
"title": "消息模板配置",
|
||||||
|
"templateType": "模板類型",
|
||||||
|
"templateContent": "模板內容",
|
||||||
|
"isDefault": "默認模板",
|
||||||
|
"isCustom": "自定義模板",
|
||||||
|
"resetToDefault": "重置為默認",
|
||||||
|
"resetConfirm": "確定要重置為默認模板嗎?您的自定義內容將丟失。",
|
||||||
|
"resetSuccess": "重置成功",
|
||||||
|
"resetFailed": "重置失敗",
|
||||||
|
"saveSuccess": "保存成功",
|
||||||
|
"saveFailed": "保存失敗",
|
||||||
|
"testSuccess": "測試消息發送成功,請檢查 Telegram",
|
||||||
|
"testFailed": "測試消息發送失敗",
|
||||||
|
"variables": "可用變量",
|
||||||
|
"clickToCopy": "點擊複製",
|
||||||
|
"copied": "已複製",
|
||||||
|
"commonVariables": "通用變量",
|
||||||
|
"orderVariables": "訂單變量",
|
||||||
|
"copyTradingVariables": "跟單變量",
|
||||||
|
"redeemVariables": "贖回變量",
|
||||||
|
"errorVariables": "錯誤變量",
|
||||||
|
"filterVariables": "過濾變量",
|
||||||
|
"strategyVariables": "策略變量",
|
||||||
|
"contentPlaceholder": "在此輸入模板內容,使用 {{變量名}} 插入動態內容",
|
||||||
|
"variableLabels": {
|
||||||
|
"account_name": "賬戶名稱",
|
||||||
|
"wallet_address": "錢包地址",
|
||||||
|
"time": "時間",
|
||||||
|
"order_id": "訂單ID",
|
||||||
|
"market_title": "市場標題",
|
||||||
|
"market_link": "市場鏈接",
|
||||||
|
"side": "方向",
|
||||||
|
"outcome": "市場方向",
|
||||||
|
"price": "價格",
|
||||||
|
"quantity": "數量",
|
||||||
|
"amount": "金額",
|
||||||
|
"available_balance": "可用餘額",
|
||||||
|
"leader_name": "Leader 名稱",
|
||||||
|
"config_name": "跟單配置名",
|
||||||
|
"error_message": "錯誤信息",
|
||||||
|
"filter_type": "過濾類型",
|
||||||
|
"filter_reason": "過濾原因",
|
||||||
|
"strategy_name": "策略名稱",
|
||||||
|
"transaction_hash": "交易哈希",
|
||||||
|
"total_value": "贖回總價值"
|
||||||
|
},
|
||||||
|
"variableDescriptions": {
|
||||||
|
"account_name": "執行訂單的賬戶名稱",
|
||||||
|
"wallet_address": "錢包地址(已脫敏)",
|
||||||
|
"time": "通知發送時間",
|
||||||
|
"order_id": "訂單唯一標識",
|
||||||
|
"market_title": "市場/事件名稱",
|
||||||
|
"market_link": "Polymarket 市場鏈接",
|
||||||
|
"side": "訂單方向(買入/賣出)",
|
||||||
|
"outcome": "市場方向(YES/NO 等)",
|
||||||
|
"price": "訂單價格",
|
||||||
|
"quantity": "訂單數量(shares)",
|
||||||
|
"amount": "訂單金額(USDC)",
|
||||||
|
"available_balance": "賬戶可用餘額(USDC)",
|
||||||
|
"leader_name": "跟單的 Leader 名稱/備註",
|
||||||
|
"config_name": "跟單配置名稱",
|
||||||
|
"error_message": "訂單失敗原因",
|
||||||
|
"filter_type": "訂單被過濾的類型",
|
||||||
|
"filter_reason": "訂單被過濾的詳細原因",
|
||||||
|
"strategy_name": "加密價差策略名稱",
|
||||||
|
"transaction_hash": "贖回交易的哈希值",
|
||||||
|
"total_value": "贖回的總價值(USDC)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templateTypes": {
|
||||||
|
"ORDER_SUCCESS": "訂單成功通知",
|
||||||
|
"ORDER_FAILED": "訂單失敗通知",
|
||||||
|
"ORDER_FILTERED": "訂單過濾通知",
|
||||||
|
"CRYPTO_TAIL_SUCCESS": "加密價差策略成功通知",
|
||||||
|
"REDEEM_SUCCESS": "倉位贖回成功通知",
|
||||||
|
"REDEEM_NO_RETURN": "倉位結算(無收益)通知"
|
||||||
|
},
|
||||||
|
"templateTypeDescriptions": {
|
||||||
|
"ORDER_SUCCESS": "訂單創建成功時發送的通知",
|
||||||
|
"ORDER_FAILED": "訂單創建失敗時發送的通知",
|
||||||
|
"ORDER_FILTERED": "訂單被風控過濾時發送的通知",
|
||||||
|
"CRYPTO_TAIL_SUCCESS": "加密價差策略下單成功時發送的通知",
|
||||||
|
"REDEEM_SUCCESS": "倉位贖回成功時發送的通知",
|
||||||
|
"REDEEM_NO_RETURN": "倉位結算但無收益時發送的通知"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"telegramConfig": {
|
"telegramConfig": {
|
||||||
"title": "Telegram 配置說明",
|
"title": "Telegram 配置說明",
|
||||||
@@ -1269,6 +1444,8 @@
|
|||||||
"title": "回測",
|
"title": "回測",
|
||||||
"taskName": "任務名稱",
|
"taskName": "任務名稱",
|
||||||
"leader": "Leader",
|
"leader": "Leader",
|
||||||
|
"balance": "資金 (初始→最終)",
|
||||||
|
"profit": "收益 (金額/比例)",
|
||||||
"initialBalance": "初始資金",
|
"initialBalance": "初始資金",
|
||||||
"backtestDays": "回測天數",
|
"backtestDays": "回測天數",
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
@@ -1317,6 +1494,7 @@
|
|||||||
"createCopyTradingSuccess": "跟單配置創建成功",
|
"createCopyTradingSuccess": "跟單配置創建成功",
|
||||||
"noTasks": "暫無回測任務",
|
"noTasks": "暫無回測任務",
|
||||||
"noTrades": "暫無交易記錄",
|
"noTrades": "暫無交易記錄",
|
||||||
|
"noData": "暫無回測數據",
|
||||||
"fetchTasksFailed": "獲取任務列表失敗",
|
"fetchTasksFailed": "獲取任務列表失敗",
|
||||||
"fetchTaskDetailFailed": "獲取任務詳情失敗",
|
"fetchTaskDetailFailed": "獲取任務詳情失敗",
|
||||||
"fetchTradesFailed": "獲取交易記錄失敗",
|
"fetchTradesFailed": "獲取交易記錄失敗",
|
||||||
@@ -1404,17 +1582,19 @@
|
|||||||
"cryptoTailStrategy": {
|
"cryptoTailStrategy": {
|
||||||
"binanceApiAlert": {
|
"binanceApiAlert": {
|
||||||
"title": "無法連接幣安 API,策略暫時不能運行",
|
"title": "無法連接幣安 API,策略暫時不能運行",
|
||||||
"description": "尾盤策略需要從幣安取得行情資料才能運作。目前連線失敗,可能是網路問題或幣安服務異常,請稍後點擊下方按鈕重新檢測。",
|
"description": "加密價差策略需要從幣安取得行情資料才能運作。目前連線失敗,可能是網路問題或幣安服務異常,請稍後點擊下方按鈕重新檢測。",
|
||||||
"recheck": "重新檢測"
|
"recheck": "重新檢測"
|
||||||
},
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"title": "加密尾盤策略",
|
"title": "加密價差策略",
|
||||||
"walletTip": "請使用單獨的錢包運行尾盤策略,避免該錢包用於手動交易、跟單等其他操作,否則可能導致餘額或倉位變化,造成策略執行異常。",
|
"walletTip": "請使用單獨的錢包運行加密價差策略,避免該錢包用於手動交易、跟單等其他操作,否則可能導致餘額或倉位變化,造成策略執行異常。",
|
||||||
"addStrategy": "新增策略",
|
"addStrategy": "新增策略",
|
||||||
"strategyName": "策略名稱",
|
"strategyName": "策略名稱",
|
||||||
"account": "賬戶",
|
"account": "賬戶",
|
||||||
"market": "關聯市場",
|
"market": "關聯市場",
|
||||||
|
"marketAndTime": "市場/時間",
|
||||||
"timeWindow": "時間區間",
|
"timeWindow": "時間區間",
|
||||||
|
"config": "配置",
|
||||||
"priceRange": "價格區間",
|
"priceRange": "價格區間",
|
||||||
"amountMode": "投入方式",
|
"amountMode": "投入方式",
|
||||||
"ratio": "比例",
|
"ratio": "比例",
|
||||||
@@ -1427,8 +1607,10 @@
|
|||||||
"disable": "停用",
|
"disable": "停用",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"viewTriggers": "訂單",
|
"viewTriggers": "訂單",
|
||||||
|
"viewPnlCurve": "收益曲線",
|
||||||
"deleteConfirm": "確定刪除該策略?",
|
"deleteConfirm": "確定刪除該策略?",
|
||||||
"fetchFailed": "獲取列表失敗"
|
"fetchFailed": "獲取列表失敗",
|
||||||
|
"configGuide": "配置指南"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"walletTip": "建議使用專用錢包,避免手動操作等導致餘額或下單異常。",
|
"walletTip": "建議使用專用錢包,避免手動操作等導致餘額或下單異常。",
|
||||||
@@ -1451,17 +1633,21 @@
|
|||||||
"update": "更新",
|
"update": "更新",
|
||||||
"timeWindowStartLEEnd": "時間區間開始不能大於結束",
|
"timeWindowStartLEEnd": "時間區間開始不能大於結束",
|
||||||
"timeWindowExceed": "時間區間不能超過週期長度",
|
"timeWindowExceed": "時間區間不能超過週期長度",
|
||||||
"minSpreadMode": "最小價差",
|
"spreadMode": "價差",
|
||||||
"minSpreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 20 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30),僅當價差 ≥ 該值時才下單;無:不校驗價差,滿足價格區間即下單。",
|
"spreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 20 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30);無:不校驗價差,滿足價格區間即下單。",
|
||||||
"minSpreadModeNone": "無",
|
"spreadModeNone": "無",
|
||||||
"minSpreadModeFixed": "固定",
|
"spreadModeFixed": "固定",
|
||||||
"minSpreadModeAuto": "自動",
|
"spreadModeAuto": "自動",
|
||||||
"minSpreadValue": "最小價差數值 (USDC)",
|
"spreadValue": "價差數值 (USDC)",
|
||||||
"minSpreadValuePlaceholder": "如 30"
|
"spreadValuePlaceholder": "如 30",
|
||||||
|
"spreadDirection": "價差方向",
|
||||||
|
"spreadDirectionTip": "最小價差:價差 ≥ 配置值時觸發,買入價固定 0.99;最大價差:價差 ≤ 配置值時觸發,買入價 = 觸發價 + 0.02(適合低價買入)。",
|
||||||
|
"spreadDirectionMin": "最小價差",
|
||||||
|
"spreadDirectionMax": "最大價差"
|
||||||
},
|
},
|
||||||
"redeemRequiredModal": {
|
"redeemRequiredModal": {
|
||||||
"title": "請先配置自動贖回",
|
"title": "請先配置自動贖回",
|
||||||
"description": "尾盤策略依賴自動贖回功能,請先在系統設置中配置 Builder API Key 並開啟自動贖回。",
|
"description": "加密價差策略依賴自動贖回功能,請先在系統設置中配置 Builder API Key 並開啟自動贖回。",
|
||||||
"goToSettings": "去配置",
|
"goToSettings": "去配置",
|
||||||
"cancel": "取消"
|
"cancel": "取消"
|
||||||
},
|
},
|
||||||
@@ -1489,6 +1675,131 @@
|
|||||||
"emptySuccess": "暫無成功記錄",
|
"emptySuccess": "暫無成功記錄",
|
||||||
"emptyFail": "暫無失敗記錄",
|
"emptyFail": "暫無失敗記錄",
|
||||||
"totalCount": "共 {count} 條"
|
"totalCount": "共 {count} 條"
|
||||||
|
},
|
||||||
|
"pnlCurve": {
|
||||||
|
"title": "收益曲線",
|
||||||
|
"totalPnl": "總收益",
|
||||||
|
"settledCount": "已結算筆數",
|
||||||
|
"winRate": "勝率",
|
||||||
|
"maxDrawdown": "最大回撤",
|
||||||
|
"timeRange": "時間範圍",
|
||||||
|
"today": "今日",
|
||||||
|
"last7Days": "近7天",
|
||||||
|
"last30Days": "近30天",
|
||||||
|
"all": "全部",
|
||||||
|
"customRange": "自定義",
|
||||||
|
"empty": "暫無已結算訂單,無法展示收益曲線"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cryptoTailMonitor": {
|
||||||
|
"title": "加密價差策略監控",
|
||||||
|
"selectStrategy": "選擇策略",
|
||||||
|
"selectStrategyPlaceholder": "請選擇要監控的策略",
|
||||||
|
"direction": "監控方向",
|
||||||
|
"directionUp": "Up",
|
||||||
|
"directionDown": "Down",
|
||||||
|
"noData": "請選擇一個策略開始監控",
|
||||||
|
"priceRange": "價格區間",
|
||||||
|
"timeWindow": "時間窗口",
|
||||||
|
"stat": {
|
||||||
|
"openPrice": "開盤價",
|
||||||
|
"currentPrice": "最新價",
|
||||||
|
"spread": "價差",
|
||||||
|
"remainingTime": "剩餘時間",
|
||||||
|
"configuredSpread": "配置價差",
|
||||||
|
"configuredSpreadMin": "最小價差",
|
||||||
|
"configuredSpreadMax": "最大價差",
|
||||||
|
"status": "狀態",
|
||||||
|
"minSpreadLine": "最小價差線",
|
||||||
|
"periodSpreadMinMax": "週期內價差",
|
||||||
|
"periodSpreadMin": "週期內最小價差",
|
||||||
|
"periodSpreadMax": "週期內最大價差",
|
||||||
|
"minSpread": "最小",
|
||||||
|
"maxSpread": "最大"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"triggered": "已觸發",
|
||||||
|
"periodEnded": "週期結束",
|
||||||
|
"inCondition": "滿足條件",
|
||||||
|
"waiting": "等待中"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"title": "分時圖",
|
||||||
|
"btcTitle": "BTC 分時圖",
|
||||||
|
"marketTitle": "市場分時圖",
|
||||||
|
"priceChart": "價格分時圖",
|
||||||
|
"price": "價格",
|
||||||
|
"openPrice": "開盤價",
|
||||||
|
"spread": "價差",
|
||||||
|
"minSpreadLine": "最小價差線",
|
||||||
|
"maxSpreadLine": "最大價差線",
|
||||||
|
"marketUp": "Up",
|
||||||
|
"marketDown": "Down",
|
||||||
|
"time": "時間",
|
||||||
|
"latestPrice": "最新價",
|
||||||
|
"timeWindowStart": "區間開始",
|
||||||
|
"timeWindowEnd": "區間結束"
|
||||||
|
},
|
||||||
|
"strategyInfo": {
|
||||||
|
"title": "策略信息",
|
||||||
|
"market": "市場",
|
||||||
|
"interval": "週期",
|
||||||
|
"account": "賬戶",
|
||||||
|
"spreadMode": "價差模式",
|
||||||
|
"spreadDirection": "價差方向"
|
||||||
|
},
|
||||||
|
"periodSwitch": {
|
||||||
|
"mode": "週期切換模式",
|
||||||
|
"auto": "自動",
|
||||||
|
"manual": "手動",
|
||||||
|
"autoDesc": "週期結束時自動切換到最新週期",
|
||||||
|
"manualDesc": "週期結束時保留完整數據",
|
||||||
|
"switchToLatest": "切換到最新週期",
|
||||||
|
"periodEnded": "當前週期已結束",
|
||||||
|
"newPeriodAvailable": "新週期已開始"
|
||||||
|
},
|
||||||
|
"manualOrder": {
|
||||||
|
"title": "手動下單",
|
||||||
|
"buttonUp": "買入 Up",
|
||||||
|
"buttonDown": "買入 Down",
|
||||||
|
"confirmTitle": "手動下單確認",
|
||||||
|
"marketTitle": "市場標題",
|
||||||
|
"direction": "方向",
|
||||||
|
"directionUp": "Up",
|
||||||
|
"directionDown": "Down",
|
||||||
|
"orderPrice": "下單價格",
|
||||||
|
"orderSize": "下單數量",
|
||||||
|
"totalAmount": "總金額",
|
||||||
|
"account": "賬戶",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "確認下單",
|
||||||
|
"orderUnit": "USDC",
|
||||||
|
"sizeUnit": "張",
|
||||||
|
"statusNotOrdered": "未下單",
|
||||||
|
"statusOrdered": "本週期已下單",
|
||||||
|
"statusOrderedAuto": "本週期已自動下單",
|
||||||
|
"statusOrderedManual": "本週期已手動下單",
|
||||||
|
"errorInsufficientBalance": "賬戶餘額不足,可用餘額:{balance} USDC",
|
||||||
|
"errorPriceOutOfRange": "價格必須在 0~1 之間",
|
||||||
|
"errorMinSize": "數量不能少於 1 張",
|
||||||
|
"errorExceedsBalance": "總金額超過可用餘額",
|
||||||
|
"errorPriceRequired": "請輸入下單價格",
|
||||||
|
"errorSizeRequired": "請輸入下單數量",
|
||||||
|
"success": "手動下單成功",
|
||||||
|
"failed": "手動下單失敗:{{reason}}",
|
||||||
|
"errorSigning": "簽名失敗:{reason}",
|
||||||
|
"errorNetwork": "網絡錯誤,請稍後重試",
|
||||||
|
"priceNotLoaded": "價格數據未加載",
|
||||||
|
"insufficientBalance": "餘額不足,可用金額少於 1 USDC",
|
||||||
|
"fetchBalanceFailed": "獲取賬戶餘額失敗",
|
||||||
|
"availableBalance": "可用餘額",
|
||||||
|
"fetchLatestPrice": "獲取最新價",
|
||||||
|
"priceUpdated": "價格已更新",
|
||||||
|
"maxSize": "最大",
|
||||||
|
"invalidPriceOrBalance": "價格或餘額無效",
|
||||||
|
"insufficientBalanceForMax": "餘額不足以購買最大數量",
|
||||||
|
"maxSizeUpdated": "已更新為最大數量",
|
||||||
|
"periodChanged": "週期已切換,彈窗已關閉"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useAccountStore } from '../store/accountStore'
|
|||||||
import type { Account } from '../types'
|
import type { Account } from '../types'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import { formatUSDC } from '../utils'
|
import { formatUSDC } from '../utils'
|
||||||
|
import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
|
|
||||||
@@ -150,10 +151,7 @@ const AccountDetail: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditModalVisible(true)
|
setEditModalVisible(true)
|
||||||
editForm.setFieldsValue({
|
editForm.setFieldsValue({
|
||||||
accountName: account.accountName || '',
|
accountName: account.accountName || ''
|
||||||
apiKey: '', // 不显示实际值,留空表示不修改
|
|
||||||
apiSecret: '', // 不显示实际值,留空表示不修改
|
|
||||||
apiPassphrase: '' // 不显示实际值,留空表示不修改
|
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
size={isMobile ? 'middle' : 'large'}
|
size={isMobile ? 'middle' : 'large'}
|
||||||
@@ -214,46 +212,23 @@ const AccountDetail: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Card
|
{accountId && (
|
||||||
title={t('account.apiCredentials')}
|
<div style={{
|
||||||
style={{
|
|
||||||
marginTop: isMobile ? '12px' : '16px',
|
marginTop: isMobile ? '12px' : '16px',
|
||||||
margin: isMobile ? '0 -8px' : '0',
|
margin: isMobile ? '0 -8px' : '0'
|
||||||
borderRadius: isMobile ? '0' : undefined
|
}}>
|
||||||
}}
|
<AccountSetupStatusBlock
|
||||||
>
|
accountId={Number(accountId)}
|
||||||
<Descriptions
|
onRefresh={() => { loadAccountDetail(); loadBalance() }}
|
||||||
column={isMobile ? 1 : 2}
|
size={isMobile ? 'small' : 'default'}
|
||||||
bordered
|
showApprovalDetails={true}
|
||||||
size={isMobile ? 'small' : 'middle'}
|
/>
|
||||||
style={{ fontSize: isMobile ? '14px' : undefined }}
|
</div>
|
||||||
>
|
)}
|
||||||
<Descriptions.Item label={t('account.apiKey')}>
|
|
||||||
<Tag color={account.apiKeyConfigured ? 'success' : 'default'}>
|
<Divider style={{ margin: isMobile ? '12px 0' : '16px 0' }} />
|
||||||
{account.apiKeyConfigured ? t('account.configured') : t('account.notConfigured')}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label={t('account.apiSecret')}>
|
|
||||||
<Tag color={account.apiSecretConfigured ? 'success' : 'default'}>
|
|
||||||
{account.apiSecretConfigured ? t('account.configured') : t('account.notConfigured')}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label={t('account.apiPassphrase')}>
|
|
||||||
<Tag color={account.apiPassphraseConfigured ? 'success' : 'default'}>
|
|
||||||
{account.apiPassphraseConfigured ? t('account.configured') : t('account.notConfigured')}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label={t('account.apiCredentials')}>
|
|
||||||
{account.apiKeyConfigured && account.apiSecretConfigured && account.apiPassphraseConfigured ? (
|
|
||||||
<Tag color="success">{t('account.fullConfig')}</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color="warning">{t('account.partialConfig')}</Tag>
|
|
||||||
)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{(account.totalOrders !== undefined || account.totalPnl !== undefined ||
|
{(account.totalOrders !== undefined || account.totalPnl !== undefined ||
|
||||||
account.activeOrders !== undefined ||
|
account.activeOrders !== undefined ||
|
||||||
account.completedOrders !== undefined || account.positionCount !== undefined) ? (
|
account.completedOrders !== undefined || account.positionCount !== undefined) ? (
|
||||||
|
|||||||
+242
-223
@@ -1,12 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert } from 'antd'
|
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert, Tooltip, List, Empty } from 'antd'
|
||||||
import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined } from '@ant-design/icons'
|
import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined, EyeOutlined, DeleteOutlined, WalletOutlined } from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAccountStore } from '../store/accountStore'
|
import { useAccountStore } from '../store/accountStore'
|
||||||
import type { Account } from '../types'
|
import type { Account } from '../types'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import { formatUSDC } from '../utils'
|
import { formatUSDC } from '../utils'
|
||||||
import AccountImportForm from '../components/AccountImportForm'
|
import AccountImportForm from '../components/AccountImportForm'
|
||||||
|
import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
|
|
||||||
@@ -204,10 +205,7 @@ const AccountList: React.FC = () => {
|
|||||||
setEditAccount(accountDetail)
|
setEditAccount(accountDetail)
|
||||||
|
|
||||||
editForm.setFieldsValue({
|
editForm.setFieldsValue({
|
||||||
accountName: accountDetail.accountName || '',
|
accountName: accountDetail.accountName || ''
|
||||||
apiKey: '', // 不显示实际值,留空表示不修改
|
|
||||||
apiSecret: '', // 不显示实际值,留空表示不修改
|
|
||||||
apiPassphrase: '' // 不显示实际值,留空表示不修改
|
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('打开编辑失败:', error)
|
console.error('打开编辑失败:', error)
|
||||||
@@ -333,171 +331,79 @@ const AccountList: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: t('accountList.action'),
|
title: t('accountList.action'),
|
||||||
key: 'action',
|
key: 'action',
|
||||||
|
width: 140,
|
||||||
render: (_: any, record: Account) => (
|
render: (_: any, record: Account) => (
|
||||||
<Space size="small">
|
<Space size={4}>
|
||||||
<Button
|
<Tooltip title={t('accountList.detail')}>
|
||||||
type="link"
|
<div
|
||||||
size="small"
|
onClick={() => handleShowDetail(record)}
|
||||||
onClick={() => handleShowDetail(record)}
|
style={{
|
||||||
>
|
display: 'flex',
|
||||||
{t('accountList.detail')}
|
alignItems: 'center',
|
||||||
</Button>
|
justifyContent: 'center',
|
||||||
<Button
|
width: '32px',
|
||||||
type="link"
|
height: '32px',
|
||||||
size="small"
|
cursor: 'pointer',
|
||||||
icon={<EditOutlined />}
|
borderRadius: '6px',
|
||||||
onClick={() => handleShowEdit(record)}
|
transition: 'background-color 0.2s'
|
||||||
>
|
}}
|
||||||
{t('accountList.edit')}
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
</Button>
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
<Popconfirm
|
|
||||||
title={t('accountList.deleteConfirm')}
|
|
||||||
description={
|
|
||||||
record.apiKeyConfigured
|
|
||||||
? t('accountList.deleteConfirmDesc')
|
|
||||||
: t('accountList.deleteConfirmDescSimple')
|
|
||||||
}
|
|
||||||
onConfirm={() => handleDelete(record)}
|
|
||||||
okText={t('accountList.deleteConfirmOk')}
|
|
||||||
cancelText={t('common.cancel')}
|
|
||||||
okButtonProps={{ danger: true }}
|
|
||||||
>
|
|
||||||
<Button type="link" size="small" danger>
|
|
||||||
{t('accountList.delete')}
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const mobileColumns = [
|
|
||||||
{
|
|
||||||
title: t('accountList.accountName'),
|
|
||||||
key: 'info',
|
|
||||||
render: (_: any, record: Account) => {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '8px 0' }}>
|
|
||||||
<div style={{
|
|
||||||
fontWeight: 'bold',
|
|
||||||
marginBottom: '8px',
|
|
||||||
fontSize: '16px'
|
|
||||||
}}>
|
|
||||||
{record.accountName || `${t('accountList.accountName')} ${record.id}`}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
color: '#666',
|
|
||||||
marginBottom: '8px',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
lineHeight: '1.4'
|
|
||||||
}}>
|
|
||||||
<div style={{ marginBottom: '4px' }}>
|
|
||||||
<strong>{t('accountList.walletAddress')}:</strong> {record.walletAddress ? `${record.walletAddress.slice(0, 6)}...${record.walletAddress.slice(-4)}` : '-'}
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleCopy(record.walletAddress)
|
|
||||||
}}
|
|
||||||
style={{ marginLeft: '4px', padding: '0 4px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: '4px' }}>
|
|
||||||
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'}
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleCopy(record.proxyAddress)
|
|
||||||
}}
|
|
||||||
style={{ marginLeft: '4px', padding: '0 4px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{record.walletType && (
|
|
||||||
<div style={{ marginBottom: '4px' }}>
|
|
||||||
<strong>{t('accountList.walletType')}:</strong>{' '}
|
|
||||||
<Tag color={record.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'} style={{ marginLeft: '4px' }}>
|
|
||||||
{record.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '500',
|
|
||||||
color: '#1890ff'
|
|
||||||
}}>
|
|
||||||
{t('accountList.totalBalance')}: {balanceLoading[record.id] ? (
|
|
||||||
<Spin size="small" style={{ marginLeft: '4px' }} />
|
|
||||||
) : balanceMap[record.id]?.total && balanceMap[record.id].total !== '-' ? (
|
|
||||||
`${formatUSDC(balanceMap[record.id].total)} USDC`
|
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{balanceMap[record.id] && balanceMap[record.id].available !== '-' && (
|
|
||||||
<div style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
color: '#666',
|
|
||||||
marginTop: '4px'
|
|
||||||
}}>
|
|
||||||
{t('accountList.available')}: {formatUSDC(balanceMap[record.id].available)} USDC | {t('accountList.position')}: {formatUSDC(balanceMap[record.id].position)} USDC
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('accountList.action'),
|
|
||||||
key: 'action',
|
|
||||||
width: 100,
|
|
||||||
render: (_: any, record: Account) => (
|
|
||||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
block
|
|
||||||
onClick={() => handleShowDetail(record)}
|
|
||||||
style={{ minHeight: '32px' }}
|
|
||||||
>
|
|
||||||
{t('accountList.viewDetail')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
block
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => handleShowEdit(record)}
|
|
||||||
style={{ minHeight: '32px' }}
|
|
||||||
>
|
|
||||||
{t('accountList.edit')}
|
|
||||||
</Button>
|
|
||||||
<Popconfirm
|
|
||||||
title={t('accountList.deleteConfirm')}
|
|
||||||
description={
|
|
||||||
record.apiKeyConfigured
|
|
||||||
? t('accountList.deleteConfirmDesc')
|
|
||||||
: t('accountList.deleteConfirmDescSimple')
|
|
||||||
}
|
|
||||||
onConfirm={() => handleDelete(record)}
|
|
||||||
okText={t('accountList.deleteConfirmOk')}
|
|
||||||
cancelText={t('common.cancel')}
|
|
||||||
okButtonProps={{ danger: true }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
block
|
|
||||||
danger
|
|
||||||
style={{ minHeight: '32px' }}
|
|
||||||
>
|
>
|
||||||
{t('accountList.delete')}
|
<EyeOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
</Button>
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('accountList.edit')}>
|
||||||
|
<div
|
||||||
|
onClick={() => handleShowEdit(record)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Popconfirm
|
||||||
|
title={t('accountList.deleteConfirm')}
|
||||||
|
description={
|
||||||
|
record.apiKeyConfigured
|
||||||
|
? t('accountList.deleteConfirmDesc')
|
||||||
|
: t('accountList.deleteConfirmDescSimple')
|
||||||
|
}
|
||||||
|
onConfirm={() => handleDelete(record)}
|
||||||
|
okText={t('accountList.deleteConfirmOk')}
|
||||||
|
cancelText={t('common.cancel')}
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Tooltip title={t('accountList.delete')}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
@@ -521,16 +427,15 @@ const AccountList: React.FC = () => {
|
|||||||
<Title level={isMobile ? 3 : 2} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}>
|
<Title level={isMobile ? 3 : 2} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}>
|
||||||
{t('accountList.title')}
|
{t('accountList.title')}
|
||||||
</Title>
|
</Title>
|
||||||
<Button
|
<Tooltip title={t('accountList.importAccount')}>
|
||||||
type="primary"
|
<Button
|
||||||
icon={<PlusOutlined />}
|
type="primary"
|
||||||
onClick={() => setAccountImportModalVisible(true)}
|
icon={<PlusOutlined />}
|
||||||
size={isMobile ? 'middle' : 'large'}
|
onClick={() => setAccountImportModalVisible(true)}
|
||||||
block={isMobile}
|
size={isMobile ? 'middle' : 'large'}
|
||||||
style={isMobile ? { minHeight: '44px' } : undefined}
|
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
|
||||||
>
|
/>
|
||||||
{t('accountList.importAccount')}
|
</Tooltip>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card style={{
|
<Card style={{
|
||||||
@@ -538,21 +443,156 @@ const AccountList: React.FC = () => {
|
|||||||
borderRadius: isMobile ? '0' : undefined
|
borderRadius: isMobile ? '0' : undefined
|
||||||
}}>
|
}}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<Table
|
loading ? (
|
||||||
dataSource={accounts}
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
columns={mobileColumns}
|
<Spin size="large" />
|
||||||
rowKey="id"
|
</div>
|
||||||
loading={loading}
|
) : accounts.length === 0 ? (
|
||||||
pagination={{
|
<Empty description={t('accountList.noData')} />
|
||||||
pageSize: 10,
|
) : (
|
||||||
showSizeChanger: false,
|
<List
|
||||||
simple: true,
|
dataSource={accounts}
|
||||||
size: 'small'
|
renderItem={(account) => {
|
||||||
}}
|
const balance = balanceMap[account.id]
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
size="small"
|
return (
|
||||||
style={{ fontSize: '14px' }}
|
<Card
|
||||||
/>
|
key={account.id}
|
||||||
|
style={{
|
||||||
|
marginBottom: '10px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: '0' }}
|
||||||
|
>
|
||||||
|
{/* 头部区域 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: 'var(--ant-color-primary, #1677ff)',
|
||||||
|
color: '#fff'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<WalletOutlined style={{ fontSize: '14px' }} />
|
||||||
|
<span>{account.accountName || `${t('accountList.accountName')} ${account.id}`}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '10px', opacity: '0.85', fontFamily: 'monospace', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span>{account.walletAddress ? `${account.walletAddress.slice(0, 6)}...${account.walletAddress.slice(-4)}` : '-'}</span>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined style={{ fontSize: '12px', color: 'rgba(255,255,255,0.85)' }} />}
|
||||||
|
onClick={() => handleCopy(account.walletAddress)}
|
||||||
|
style={{ padding: '0 4px', height: 'auto' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 资产区域 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
minHeight: '42px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
|
||||||
|
{t('accountList.totalBalance')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}>
|
||||||
|
{balance?.total && balance.total !== '-' ? `${formatUSDC(balance.total)} USDC` : '- USDC'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
|
||||||
|
{t('accountList.walletType')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px' }}>
|
||||||
|
{account.walletType ? (
|
||||||
|
<Tag color={account.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'} style={{ margin: 0 }}>
|
||||||
|
{account.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
|
||||||
|
</Tag>
|
||||||
|
) : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 地址信息区域 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#8c8c8c',
|
||||||
|
borderBottom: '1px solid #f0f0f0'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span>{t('accountList.proxyAddress')}: {account.proxyAddress ? `${account.proxyAddress.slice(0, 6)}...${account.proxyAddress.slice(-4)}` : '-'}</span>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined style={{ fontSize: '12px' }} />}
|
||||||
|
onClick={() => handleCopy(account.proxyAddress)}
|
||||||
|
style={{ padding: '0 4px', height: 'auto' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图标操作栏 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<Tooltip title={t('accountList.detail')}>
|
||||||
|
<div
|
||||||
|
onClick={() => handleShowDetail(account)}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
<EyeOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('accountList.detail')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('accountList.edit')}>
|
||||||
|
<div
|
||||||
|
onClick={() => handleShowEdit(account)}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
<EditOutlined style={{ fontSize: '18px', color: '#52c41a' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('accountList.edit')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Popconfirm
|
||||||
|
title={t('accountList.deleteConfirm')}
|
||||||
|
description={
|
||||||
|
account.apiKeyConfigured
|
||||||
|
? t('accountList.deleteConfirmDesc')
|
||||||
|
: t('accountList.deleteConfirmDescSimple')
|
||||||
|
}
|
||||||
|
onConfirm={() => handleDelete(account)}
|
||||||
|
okText={t('accountList.deleteConfirmOk')}
|
||||||
|
cancelText={t('common.cancel')}
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Tooltip title={t('accountList.delete')}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
|
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('accountList.delete')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<Table
|
<Table
|
||||||
dataSource={accounts}
|
dataSource={accounts}
|
||||||
@@ -720,35 +760,14 @@ const AccountList: React.FC = () => {
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Descriptions
|
<AccountSetupStatusBlock
|
||||||
column={isMobile ? 1 : 2}
|
accountId={detailAccount.id}
|
||||||
bordered
|
onRefresh={handleRefreshDetailBalance}
|
||||||
size={isMobile ? 'small' : 'middle'}
|
size={isMobile ? 'small' : 'default'}
|
||||||
title={t('accountList.apiCredentials')}
|
showApprovalDetails={true}
|
||||||
>
|
/>
|
||||||
<Descriptions.Item label={t('accountList.apiKey')}>
|
|
||||||
<Tag color={detailAccount.apiKeyConfigured ? 'success' : 'default'}>
|
<Divider />
|
||||||
{detailAccount.apiKeyConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label={t('accountList.apiSecret')}>
|
|
||||||
<Tag color={detailAccount.apiSecretConfigured ? 'success' : 'default'}>
|
|
||||||
{detailAccount.apiSecretConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label={t('accountList.apiPassphrase')}>
|
|
||||||
<Tag color={detailAccount.apiPassphraseConfigured ? 'success' : 'default'}>
|
|
||||||
{detailAccount.apiPassphraseConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label={t('accountList.configStatus')}>
|
|
||||||
{detailAccount.apiKeyConfigured && detailAccount.apiSecretConfigured && detailAccount.apiPassphraseConfigured ? (
|
|
||||||
<Tag color="success">{t('accountList.fullConfig')}</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color="warning">{t('accountList.partialConfig')}</Tag>
|
|
||||||
)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
|
|
||||||
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
|
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
|
||||||
detailAccount.activeOrders !== undefined ||
|
detailAccount.activeOrders !== undefined ||
|
||||||
|
|||||||
+396
-240
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch, Statistic, Descriptions } from 'antd'
|
import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch, Statistic, Descriptions, List, Empty, Spin, Tooltip, Popconfirm } from 'antd'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons'
|
import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons'
|
||||||
import { formatUSDC } from '../utils'
|
import { formatUSDC } from '../utils'
|
||||||
@@ -118,49 +118,35 @@ const BacktestList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 删除任务
|
// 删除任务
|
||||||
const handleDelete = (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
Modal.confirm({
|
try {
|
||||||
title: t('backtest.deleteConfirm'),
|
const response = await backtestService.delete({ id })
|
||||||
okText: t('common.confirm'),
|
if (response.data.code === 0) {
|
||||||
cancelText: t('common.cancel'),
|
message.success(t('backtest.deleteSuccess'))
|
||||||
onOk: async () => {
|
fetchTasks()
|
||||||
try {
|
} else {
|
||||||
const response = await backtestService.delete({ id })
|
message.error(response.data.msg || t('backtest.deleteFailed'))
|
||||||
if (response.data.code === 0) {
|
|
||||||
message.success(t('backtest.deleteSuccess'))
|
|
||||||
fetchTasks()
|
|
||||||
} else {
|
|
||||||
message.error(response.data.msg || t('backtest.deleteFailed'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete backtest task:', error)
|
|
||||||
message.error(t('backtest.deleteFailed'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
} catch (error) {
|
||||||
|
console.error('Failed to delete backtest task:', error)
|
||||||
|
message.error(t('backtest.deleteFailed'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止任务
|
// 停止任务
|
||||||
const handleStop = (id: number) => {
|
const handleStop = async (id: number) => {
|
||||||
Modal.confirm({
|
try {
|
||||||
title: t('backtest.stopConfirm'),
|
const response = await backtestService.stop({ id })
|
||||||
okText: t('common.confirm'),
|
if (response.data.code === 0) {
|
||||||
cancelText: t('common.cancel'),
|
message.success(t('backtest.stopSuccess'))
|
||||||
onOk: async () => {
|
fetchTasks()
|
||||||
try {
|
} else {
|
||||||
const response = await backtestService.stop({ id })
|
message.error(response.data.msg || t('backtest.stopFailed'))
|
||||||
if (response.data.code === 0) {
|
|
||||||
message.success(t('backtest.stopSuccess'))
|
|
||||||
fetchTasks()
|
|
||||||
} else {
|
|
||||||
message.error(response.data.msg || t('backtest.stopFailed'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to stop backtest task:', error)
|
|
||||||
message.error(t('backtest.stopFailed'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
} catch (error) {
|
||||||
|
console.error('Failed to stop backtest task:', error)
|
||||||
|
message.error(t('backtest.stopFailed'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按配置重新测试(仅已完成任务)
|
// 按配置重新测试(仅已完成任务)
|
||||||
@@ -502,168 +488,227 @@ const BacktestList: React.FC = () => {
|
|||||||
title: t('backtest.taskName'),
|
title: t('backtest.taskName'),
|
||||||
dataIndex: 'taskName',
|
dataIndex: 'taskName',
|
||||||
key: 'taskName',
|
key: 'taskName',
|
||||||
width: isMobile ? 120 : 150
|
width: isMobile ? 120 : 140,
|
||||||
|
ellipsis: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('backtest.leader'),
|
title: t('backtest.leader'),
|
||||||
dataIndex: 'leaderName',
|
dataIndex: 'leaderName',
|
||||||
key: 'leaderName',
|
key: 'leaderName',
|
||||||
width: isMobile ? 100 : 150,
|
width: isMobile ? 100 : 120,
|
||||||
|
ellipsis: true,
|
||||||
render: (_: any, record: BacktestTaskDto) => record.leaderName || `Leader ${record.leaderId}`
|
render: (_: any, record: BacktestTaskDto) => record.leaderName || `Leader ${record.leaderId}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('backtest.initialBalance'),
|
title: t('backtest.balance'),
|
||||||
dataIndex: 'initialBalance',
|
key: 'balance',
|
||||||
key: 'initialBalance',
|
width: 160,
|
||||||
width: 120,
|
render: (_: any, record: BacktestTaskDto) => (
|
||||||
render: (value: string) => formatUSDC(value)
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: '#8c8c8c' }}>{formatUSDC(record.initialBalance)}</div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{record.finalBalance ? formatUSDC(record.finalBalance) : '-'}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('backtest.finalBalance'),
|
title: t('backtest.profit'),
|
||||||
dataIndex: 'finalBalance',
|
key: 'profit',
|
||||||
key: 'finalBalance',
|
width: 140,
|
||||||
width: 120,
|
render: (_: any, record: BacktestTaskDto) => {
|
||||||
render: (value: string | null) => value ? formatUSDC(value) : '-'
|
const profitAmount = record.profitAmount ? parseFloat(record.profitAmount) : null
|
||||||
},
|
const profitRate = record.profitRate ? parseFloat(record.profitRate) : null
|
||||||
{
|
const color = profitAmount !== null ? (profitAmount >= 0 ? '#52c41a' : '#ff4d4f') : undefined
|
||||||
title: t('backtest.profitAmount'),
|
return (
|
||||||
dataIndex: 'profitAmount',
|
<div>
|
||||||
key: 'profitAmount',
|
<div style={{ fontWeight: 500, color }}>
|
||||||
width: 120,
|
{profitAmount !== null ? formatUSDC(record.profitAmount) : '-'}
|
||||||
render: (value: string | null) => value ? (
|
</div>
|
||||||
<span style={{ color: parseFloat(value) >= 0 ? '#52c41a' : '#ff4d4f' }}>
|
<div style={{ fontSize: 12, color }}>
|
||||||
{formatUSDC(value)}
|
{profitRate !== null ? `${record.profitRate}%` : '-'}
|
||||||
</span>
|
</div>
|
||||||
) : '-'
|
</div>
|
||||||
},
|
)
|
||||||
{
|
}
|
||||||
title: t('backtest.profitRate'),
|
|
||||||
dataIndex: 'profitRate',
|
|
||||||
key: 'profitRate',
|
|
||||||
width: 100,
|
|
||||||
render: (value: string | null) => value ? (
|
|
||||||
<span style={{ color: parseFloat(value) >= 0 ? '#52c41a' : '#ff4d4f' }}>
|
|
||||||
{value}%
|
|
||||||
</span>
|
|
||||||
) : '-'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('backtest.backtestDays'),
|
|
||||||
dataIndex: 'backtestDays',
|
|
||||||
key: 'backtestDays',
|
|
||||||
width: 100,
|
|
||||||
render: (value: number) => `${value} ${t('common.day')}`
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('backtest.status'),
|
title: t('backtest.status'),
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
width: 100,
|
width: 130,
|
||||||
render: (status: string) => (
|
render: (status: string, record: BacktestTaskDto) => {
|
||||||
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
|
const isRunning = status === 'RUNNING' || status === 'PENDING'
|
||||||
)
|
return (
|
||||||
},
|
<div>
|
||||||
{
|
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
|
||||||
title: t('backtest.progress'),
|
{isRunning && record.progress !== undefined && (
|
||||||
dataIndex: 'progress',
|
<div style={{ marginTop: 4, fontSize: 11, color: '#8c8c8c' }}>
|
||||||
key: 'progress',
|
{record.progress}%
|
||||||
width: 120,
|
</div>
|
||||||
render: (progress: number) => (
|
)}
|
||||||
<div style={{ width: '100%' }}>
|
|
||||||
<div style={{ marginBottom: 4 }}>{progress}%</div>
|
|
||||||
<div style={{ width: '100%', height: 6, backgroundColor: '#f0f0f0', borderRadius: 3 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: `${progress}%`,
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: progress === 100 ? '#52c41a' : '#1890ff',
|
|
||||||
borderRadius: 3,
|
|
||||||
transition: 'width 0.3s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('backtest.totalTrades'),
|
title: t('backtest.totalTrades'),
|
||||||
dataIndex: 'totalTrades',
|
dataIndex: 'totalTrades',
|
||||||
key: 'totalTrades',
|
key: 'totalTrades',
|
||||||
width: 100
|
width: 80,
|
||||||
|
align: 'center' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('backtest.createdAt'),
|
title: t('backtest.createdAt'),
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
key: 'createdAt',
|
key: 'createdAt',
|
||||||
width: isMobile ? 150 : 180,
|
width: isMobile ? 150 : 150,
|
||||||
render: (timestamp: number) => new Date(timestamp).toLocaleString()
|
render: (timestamp: number) => new Date(timestamp).toLocaleString()
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('common.actions'),
|
title: t('common.actions'),
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
fixed: isMobile ? false : ('right' as const),
|
fixed: isMobile ? false : ('right' as const),
|
||||||
width: isMobile ? 100 : 150,
|
width: isMobile ? 100 : 180,
|
||||||
render: (_: any, record: BacktestTaskDto) => (
|
render: (_: any, record: BacktestTaskDto) => (
|
||||||
<Space size="small">
|
<Space size={4}>
|
||||||
<Button
|
<Tooltip title={t('common.viewDetail')}>
|
||||||
type="link"
|
<div
|
||||||
size="small"
|
onClick={() => handleViewDetail(record.id)}
|
||||||
icon={<EyeOutlined />}
|
style={{
|
||||||
onClick={() => handleViewDetail(record.id)}
|
display: 'flex',
|
||||||
>
|
alignItems: 'center',
|
||||||
{t('common.viewDetail')}
|
justifyContent: 'center',
|
||||||
</Button>
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<EyeOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{record.status === 'COMPLETED' && (
|
{record.status === 'COMPLETED' && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Tooltip title={t('backtest.rerun')}>
|
||||||
type="link"
|
<div
|
||||||
size="small"
|
onClick={() => handleRerun(record)}
|
||||||
icon={<SyncOutlined />}
|
style={{
|
||||||
onClick={() => handleRerun(record)}
|
display: 'flex',
|
||||||
>
|
alignItems: 'center',
|
||||||
{t('backtest.rerun')}
|
justifyContent: 'center',
|
||||||
</Button>
|
width: '32px',
|
||||||
<Button
|
height: '32px',
|
||||||
type="link"
|
cursor: 'pointer',
|
||||||
size="small"
|
borderRadius: '6px',
|
||||||
icon={<CopyOutlined />}
|
transition: 'background-color 0.2s'
|
||||||
onClick={() => handleCreateCopyTrading(record)}
|
}}
|
||||||
>
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
{t('backtest.createCopyTrading')}
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
</Button>
|
>
|
||||||
|
<SyncOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('backtest.createCopyTrading')}>
|
||||||
|
<div
|
||||||
|
onClick={() => handleCreateCopyTrading(record)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<CopyOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{record.status === 'RUNNING' && (
|
{record.status === 'RUNNING' && (
|
||||||
<Button
|
<Popconfirm
|
||||||
type="link"
|
title={t('backtest.stopConfirm')}
|
||||||
size="small"
|
onConfirm={() => handleStop(record.id)}
|
||||||
danger
|
okText={t('common.confirm')}
|
||||||
icon={<StopOutlined />}
|
cancelText={t('common.cancel')}
|
||||||
onClick={() => handleStop(record.id)}
|
|
||||||
>
|
>
|
||||||
{t('backtest.stop')}
|
<Tooltip title={t('backtest.stop')}>
|
||||||
</Button>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<StopOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(record.status === 'STOPPED' || record.status === 'FAILED') && (
|
{(record.status === 'STOPPED' || record.status === 'FAILED') && (
|
||||||
<Button
|
<Tooltip title={t('backtest.retry')}>
|
||||||
type="link"
|
<div
|
||||||
size="small"
|
onClick={() => handleRetry(record.id)}
|
||||||
icon={<RedoOutlined />}
|
style={{
|
||||||
onClick={() => handleRetry(record.id)}
|
display: 'flex',
|
||||||
>
|
alignItems: 'center',
|
||||||
{t('backtest.retry')}
|
justifyContent: 'center',
|
||||||
</Button>
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<RedoOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(record.status === 'PENDING' || record.status === 'COMPLETED' || record.status === 'STOPPED' || record.status === 'FAILED') && (
|
{(record.status === 'PENDING' || record.status === 'COMPLETED' || record.status === 'STOPPED' || record.status === 'FAILED') && (
|
||||||
<Button
|
<Popconfirm
|
||||||
type="link"
|
title={t('backtest.deleteConfirm')}
|
||||||
size="small"
|
onConfirm={() => handleDelete(record.id)}
|
||||||
danger
|
okText={t('common.confirm')}
|
||||||
icon={<DeleteOutlined />}
|
cancelText={t('common.cancel')}
|
||||||
onClick={() => handleDelete(record.id)}
|
|
||||||
>
|
>
|
||||||
{t('common.delete')}
|
<Tooltip title={t('common.delete')}>
|
||||||
</Button>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
@@ -671,94 +716,205 @@ const BacktestList: React.FC = () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: isMobile ? 0 : 24 }}>
|
||||||
<Card>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px', padding: isMobile ? '0 8px' : 0 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('backtest.title')}</h2>
|
||||||
|
<Space size={8}>
|
||||||
|
<Tooltip title={t('backtest.createTask')}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
size={isMobile ? 'middle' : 'large'}
|
||||||
|
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('common.refresh')}>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={loading} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
|
||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
{/* 头部操作栏 */}
|
{/* 筛选栏 */}
|
||||||
<Row justify="space-between" align="middle" gutter={[16, 16]}>
|
<Row gutter={[16, 16]} style={{ marginBottom: isMobile ? 12 : 0 }}>
|
||||||
<Col xs={24} sm={24} md={12} lg={16}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Space size="middle" direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
|
<LeaderSelect
|
||||||
<LeaderSelect
|
style={{ width: '100%' }}
|
||||||
style={{ width: isMobile ? '100%' : 180 }}
|
placeholder={t('backtest.leader')}
|
||||||
placeholder={t('backtest.leader')}
|
allowClear
|
||||||
allowClear
|
value={leaderIdFilter}
|
||||||
value={leaderIdFilter}
|
onChange={(value) => setLeaderIdFilter(value)}
|
||||||
onChange={(value) => setLeaderIdFilter(value)}
|
leaders={leaders}
|
||||||
leaders={leaders}
|
/>
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
style={{ width: isMobile ? '100%' : 150 }}
|
|
||||||
placeholder={t('backtest.status')}
|
|
||||||
allowClear
|
|
||||||
onChange={(value) => setStatusFilter(value)}
|
|
||||||
value={statusFilter}
|
|
||||||
>
|
|
||||||
<Select.Option value="PENDING">{t('backtest.statusPending')}</Select.Option>
|
|
||||||
<Select.Option value="RUNNING">{t('backtest.statusRunning')}</Select.Option>
|
|
||||||
<Select.Option value="COMPLETED">{t('backtest.statusCompleted')}</Select.Option>
|
|
||||||
<Select.Option value="STOPPED">{t('backtest.statusStopped')}</Select.Option>
|
|
||||||
<Select.Option value="FAILED">{t('backtest.statusFailed')}</Select.Option>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
style={{ width: isMobile ? '100%' : 150 }}
|
|
||||||
placeholder={t('backtest.sortBy')}
|
|
||||||
onChange={(value) => setSortBy(value)}
|
|
||||||
value={sortBy}
|
|
||||||
>
|
|
||||||
<Select.Option value="profitAmount">{t('backtest.profitAmount')}</Select.Option>
|
|
||||||
<Select.Option value="profitRate">{t('backtest.profitRate')}</Select.Option>
|
|
||||||
<Select.Option value="createdAt">{t('backtest.createdAt')}</Select.Option>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
style={{ width: isMobile ? '100%' : 120 }}
|
|
||||||
placeholder={t('backtest.sortOrder')}
|
|
||||||
onChange={(value) => setSortOrder(value)}
|
|
||||||
value={sortOrder}
|
|
||||||
>
|
|
||||||
<Select.Option value="asc">{t('common.ascending')}</Select.Option>
|
|
||||||
<Select.Option value="desc">{t('common.descending')}</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Space>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={24} md={12} lg={8} style={{ textAlign: isMobile ? 'left' : 'right' }}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Space style={{ width: isMobile ? '100%' : 'auto' }}>
|
<Select
|
||||||
<Button
|
style={{ width: '100%' }}
|
||||||
type="primary"
|
placeholder={t('backtest.status')}
|
||||||
icon={<PlusOutlined />}
|
allowClear
|
||||||
onClick={handleCreate}
|
onChange={(value) => setStatusFilter(value)}
|
||||||
style={{ flex: isMobile ? 1 : undefined }}
|
value={statusFilter}
|
||||||
>
|
>
|
||||||
{isMobile ? t('common.create') : t('backtest.createTask')}
|
<Select.Option value="PENDING">{t('backtest.statusPending')}</Select.Option>
|
||||||
</Button>
|
<Select.Option value="RUNNING">{t('backtest.statusRunning')}</Select.Option>
|
||||||
<Button
|
<Select.Option value="COMPLETED">{t('backtest.statusCompleted')}</Select.Option>
|
||||||
icon={<ReloadOutlined />}
|
<Select.Option value="STOPPED">{t('backtest.statusStopped')}</Select.Option>
|
||||||
onClick={handleRefresh}
|
<Select.Option value="FAILED">{t('backtest.statusFailed')}</Select.Option>
|
||||||
loading={loading}
|
</Select>
|
||||||
style={{ flex: isMobile ? 1 : undefined }}
|
</Col>
|
||||||
>
|
<Col xs={24} sm={12} md={6}>
|
||||||
{t('common.refresh')}
|
<Select style={{ width: '100%' }} placeholder={t('backtest.sortBy')} onChange={(value) => setSortBy(value)} value={sortBy}>
|
||||||
</Button>
|
<Select.Option value="profitAmount">{t('backtest.profitAmount')}</Select.Option>
|
||||||
</Space>
|
<Select.Option value="profitRate">{t('backtest.profitRate')}</Select.Option>
|
||||||
|
<Select.Option value="createdAt">{t('backtest.createdAt')}</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Select style={{ width: '100%' }} placeholder={t('backtest.sortOrder')} onChange={(value) => setSortOrder(value)} value={sortOrder}>
|
||||||
|
<Select.Option value="asc">{t('common.ascending')}</Select.Option>
|
||||||
|
<Select.Option value="desc">{t('common.descending')}</Select.Option>
|
||||||
|
</Select>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* 数据表格 */}
|
{/* 数据:移动端卡片 / 桌面端表格 */}
|
||||||
<Table
|
{isMobile ? (
|
||||||
columns={columns}
|
<>
|
||||||
dataSource={tasks}
|
{loading ? (
|
||||||
rowKey="id"
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
loading={loading}
|
<Spin size="large" />
|
||||||
pagination={{
|
</div>
|
||||||
current: page,
|
) : tasks.length === 0 ? (
|
||||||
pageSize: size,
|
<Empty description={t('backtest.noData') || t('common.noData')} />
|
||||||
total,
|
) : (
|
||||||
showSizeChanger: false,
|
<List
|
||||||
showTotal: (total) => `${t('common.total')} ${total} ${t('common.items')}`,
|
dataSource={tasks}
|
||||||
onChange: (newPage) => setPage(newPage),
|
renderItem={(task) => (
|
||||||
simple: isMobile
|
<Card
|
||||||
}}
|
key={task.id}
|
||||||
scroll={isMobile ? { x: 1200 } : { x: 1400 }}
|
style={{
|
||||||
/>
|
marginBottom: '10px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: 'var(--ant-color-primary, #1677ff)',
|
||||||
|
color: '#fff'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px' }}>{task.taskName}</div>
|
||||||
|
<div style={{ fontSize: '12px', opacity: '0.9' }}>{task.leaderName || `Leader ${task.leaderId}`}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '8px 12px', backgroundColor: '#fafafa', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('backtest.profitAmount')}</div>
|
||||||
|
{task.profitAmount != null ? (
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '600', color: parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>{formatUSDC(task.profitAmount)} USDC</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('backtest.profitRate')}</div>
|
||||||
|
{task.profitRate != null ? (
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '600', color: parseFloat(task.profitRate) >= 0 ? '#52c41a' : '#ff4d4f' }}>{task.profitRate}%</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('backtest.status')}</div>
|
||||||
|
<Tag color={getStatusColor(task.status)}>{getStatusText(task.status)}</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '6px 12px', fontSize: '11px', color: '#8c8c8c', borderBottom: '1px solid #f0f0f0' }}>
|
||||||
|
{t('backtest.initialBalance')}: {formatUSDC(task.initialBalance)} · {t('backtest.backtestDays')}: {task.backtestDays} {t('common.day')} · {t('backtest.progress')}: {task.progress}%
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-around', alignItems: 'center', flexWrap: 'wrap', gap: 4 }}>
|
||||||
|
<Tooltip title={t('common.viewDetail')}>
|
||||||
|
<div onClick={() => handleViewDetail(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
|
<EyeOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.viewDetail')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
{task.status === 'COMPLETED' && (
|
||||||
|
<>
|
||||||
|
<Tooltip title={t('backtest.rerun')}>
|
||||||
|
<div onClick={() => handleRerun(task)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
|
<SyncOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('backtest.rerun')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('backtest.createCopyTrading')}>
|
||||||
|
<div onClick={() => handleCreateCopyTrading(task)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
|
<CopyOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('backtest.createCopyTrading')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{task.status === 'RUNNING' && (
|
||||||
|
<Tooltip title={t('backtest.stop')}>
|
||||||
|
<div onClick={() => handleStop(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
|
<StopOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('backtest.stop')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{(task.status === 'STOPPED' || task.status === 'FAILED') && (
|
||||||
|
<Tooltip title={t('backtest.retry')}>
|
||||||
|
<div onClick={() => handleRetry(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
|
<RedoOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('backtest.retry')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{(task.status === 'PENDING' || task.status === 'COMPLETED' || task.status === 'STOPPED' || task.status === 'FAILED') && (
|
||||||
|
<Tooltip title={t('common.delete')}>
|
||||||
|
<div onClick={() => handleDelete(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
|
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* 移动端分页 */}
|
||||||
|
{!loading && tasks.length > 0 && total > size && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 16 }}>
|
||||||
|
<Button size="small" disabled={page <= 1} onClick={() => setPage(p => Math.max(1, p - 1))}>{t('common.prev')}</Button>
|
||||||
|
<span style={{ margin: '0 12px', lineHeight: '24px' }}>{page} / {Math.ceil(total / size)}</span>
|
||||||
|
<Button size="small" disabled={page >= Math.ceil(total / size)} onClick={() => setPage(p => p + 1)}>{t('common.next')}</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={tasks}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize: size,
|
||||||
|
total,
|
||||||
|
showSizeChanger: false,
|
||||||
|
showTotal: (totalCount) => `${t('common.total')} ${totalCount} ${t('common.items')}`,
|
||||||
|
onChange: (newPage) => setPage(newPage),
|
||||||
|
simple: isMobile
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1000 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Divider, Spin } from 'antd'
|
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Spin, List, Empty, Tooltip } from 'antd'
|
||||||
import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined } from '@ant-design/icons'
|
import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined, WalletOutlined, UserOutlined } from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
@@ -297,7 +297,7 @@ const CopyTradingList: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: t('common.actions') || '操作',
|
title: t('common.actions') || '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: isMobile ? 100 : 200,
|
width: isMobile ? 100 : 160,
|
||||||
fixed: 'right' as const,
|
fixed: 'right' as const,
|
||||||
render: (_: any, record: CopyTrading) => {
|
render: (_: any, record: CopyTrading) => {
|
||||||
const menuItems: MenuProps['items'] = [
|
const menuItems: MenuProps['items'] = [
|
||||||
@@ -321,61 +321,101 @@ const CopyTradingList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space size={isMobile ? 'small' : 'middle'} wrap>
|
<Space size={4}>
|
||||||
{!isMobile && (
|
<Tooltip title={t('common.edit') || '编辑'}>
|
||||||
<>
|
<div
|
||||||
<Button
|
onClick={() => {
|
||||||
type="link"
|
setEditModalCopyTradingId(record.id.toString())
|
||||||
size="small"
|
setEditModalOpen(true)
|
||||||
icon={<EditOutlined />}
|
}}
|
||||||
onClick={() => {
|
style={{
|
||||||
setEditModalCopyTradingId(record.id.toString())
|
display: 'flex',
|
||||||
setEditModalOpen(true)
|
alignItems: 'center',
|
||||||
}}
|
justifyContent: 'center',
|
||||||
>
|
width: '32px',
|
||||||
{t('common.edit') || '编辑'}
|
height: '32px',
|
||||||
</Button>
|
cursor: 'pointer',
|
||||||
<Button
|
borderRadius: '6px',
|
||||||
type="link"
|
transition: 'background-color 0.2s'
|
||||||
size="small"
|
}}
|
||||||
icon={<BarChartOutlined />}
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
|
||||||
onClick={() => {
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
setStatisticsModalCopyTradingId(record.id.toString())
|
>
|
||||||
setStatisticsModalOpen(true)
|
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
}}
|
</div>
|
||||||
>
|
</Tooltip>
|
||||||
{t('copyTradingList.statistics') || '统计'}
|
|
||||||
</Button>
|
<Tooltip title={t('copyTradingList.statistics') || '统计'}>
|
||||||
</>
|
<div
|
||||||
)}
|
onClick={() => {
|
||||||
|
setStatisticsModalCopyTradingId(record.id.toString())
|
||||||
|
setStatisticsModalOpen(true)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
|
>
|
||||||
|
<BarChartOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Dropdown menu={{ items: menuItems }} trigger={['click']}>
|
<Dropdown menu={{ items: menuItems }} trigger={['click']}>
|
||||||
<Button
|
<Tooltip title={t('copyTradingList.orders') || '订单'}>
|
||||||
type="link"
|
<div
|
||||||
size="small"
|
style={{
|
||||||
icon={<UnorderedListOutlined />}
|
display: 'flex',
|
||||||
>
|
alignItems: 'center',
|
||||||
{isMobile ? '' : (t('copyTradingList.orders') || '订单')}
|
justifyContent: 'center',
|
||||||
</Button>
|
width: '32px',
|
||||||
</Dropdown>
|
height: '32px',
|
||||||
{!isMobile && (
|
cursor: 'pointer',
|
||||||
<Popconfirm
|
borderRadius: '6px',
|
||||||
title={t('copyTradingList.deleteConfirm') || '确定要删除这个跟单关系吗?'}
|
transition: 'background-color 0.2s'
|
||||||
onConfirm={() => handleDelete(record.id)}
|
}}
|
||||||
okText={t('common.confirm') || '确定'}
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
|
||||||
cancelText={t('common.cancel') || '取消'}
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
>
|
>
|
||||||
{t('common.delete') || '删除'}
|
<UnorderedListOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
</Button>
|
</div>
|
||||||
</Popconfirm>
|
</Tooltip>
|
||||||
)}
|
</Dropdown>
|
||||||
|
|
||||||
|
<Popconfirm
|
||||||
|
title={t('copyTradingList.deleteConfirm') || '确定要删除这个跟单关系吗?'}
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
okText={t('common.confirm') || '确定'}
|
||||||
|
cancelText={t('common.cancel') || '取消'}
|
||||||
|
>
|
||||||
|
<Tooltip title={t('common.delete') || '删除'}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#fff1f0' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
|
>
|
||||||
|
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -384,19 +424,21 @@ const CopyTradingList: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
|
||||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
|
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('copyTradingList.title') || '跟单配置管理'}</h2>
|
||||||
<h2 style={{ margin: 0 }}>{t('copyTradingList.title') || '跟单配置管理'}</h2>
|
<Tooltip title={t('copyTradingList.addCopyTrading') || '新增跟单'}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => setAddModalOpen(true)}
|
onClick={() => setAddModalOpen(true)}
|
||||||
>
|
size={isMobile ? 'middle' : 'large'}
|
||||||
{t('copyTradingList.addCopyTrading') || '新增跟单'}
|
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
|
||||||
|
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<Select
|
<Select
|
||||||
placeholder={t('copyTradingList.filterWallet') || '筛选钱包'}
|
placeholder={t('copyTradingList.filterWallet') || '筛选钱包'}
|
||||||
allowClear
|
allowClear
|
||||||
@@ -421,14 +463,14 @@ const CopyTradingList: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
placeholder="筛选状态"
|
placeholder={t('common.status') || '状态'}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: isMobile ? '100%' : 150 }}
|
style={{ width: isMobile ? '100%' : 150 }}
|
||||||
value={filters.enabled}
|
value={filters.enabled}
|
||||||
onChange={(value) => setFilters({ ...filters, enabled: value !== undefined ? value : undefined })}
|
onChange={(value) => setFilters({ ...filters, enabled: value !== undefined ? value : undefined })}
|
||||||
>
|
>
|
||||||
<Option value={true}>开启</Option>
|
<Option value={true}>{t('common.enabled') || '开启'}</Option>
|
||||||
<Option value={false}>停止</Option>
|
<Option value={false}>{t('common.disabled') || '停止'}</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -440,207 +482,180 @@ const CopyTradingList: React.FC = () => {
|
|||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
) : copyTradings.length === 0 ? (
|
) : copyTradings.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
<Empty description={t('copyTradingList.noData') || '暂无跟单配置'} />
|
||||||
暂无跟单配置
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<List
|
||||||
{copyTradings.map((record) => {
|
dataSource={copyTradings}
|
||||||
|
renderItem={(record) => {
|
||||||
const stats = statisticsMap[record.id]
|
const stats = statisticsMap[record.id]
|
||||||
const date = new Date(record.createdAt)
|
|
||||||
const formattedDate = date.toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={record.id}
|
key={record.id}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: '12px',
|
marginBottom: '10px',
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
borderRadius: '10px',
|
||||||
border: '1px solid #e8e8e8'
|
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
overflow: 'hidden'
|
||||||
}}
|
}}
|
||||||
bodyStyle={{ padding: '16px' }}
|
bodyStyle={{ padding: '0' }}
|
||||||
>
|
>
|
||||||
{/* 基本信息 */}
|
{/* 头部区域 - 配置名称 */}
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{
|
||||||
<div style={{
|
padding: '10px 12px',
|
||||||
fontSize: '18px',
|
background: 'var(--ant-color-primary, #1677ff)',
|
||||||
fontWeight: 'bold',
|
color: '#fff'
|
||||||
marginBottom: '8px',
|
}}>
|
||||||
color: '#1890ff'
|
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
}}>
|
<span>{record.configName || t('copyTradingList.configNameNotProvided') || '未提供'}</span>
|
||||||
{record.configName || t('copyTradingList.configNameNotProvided') || '未提供'}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
marginBottom: '8px',
|
|
||||||
color: '#666'
|
|
||||||
}}>
|
|
||||||
{record.copyMode === 'RATIO'
|
|
||||||
? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(2).replace(/\.0+$/, '')}%`
|
|
||||||
: `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')}`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
||||||
<Tag color={record.enabled ? 'green' : 'red'}>
|
|
||||||
{record.enabled ? '启用' : '禁用'}
|
|
||||||
</Tag>
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={record.enabled}
|
checked={record.enabled}
|
||||||
onChange={() => handleToggleStatus(record)}
|
onChange={() => handleToggleStatus(record)}
|
||||||
checkedChildren="开启"
|
checkedChildren={t('copyTradingList.enabled') || '开启'}
|
||||||
unCheckedChildren="停止"
|
unCheckedChildren={t('copyTradingList.disabled') || '停止'}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div style={{ fontSize: '12px', opacity: '0.9' }}>
|
||||||
|
{record.copyMode === 'RATIO'
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%`
|
||||||
|
: `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')} USDC`
|
||||||
{/* 账户信息 */}
|
}
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>账户</div>
|
|
||||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
|
||||||
{record.accountName || `账户 ${record.accountId}`}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}>
|
|
||||||
{record.walletAddress.slice(0, 6)}...{record.walletAddress.slice(-4)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Leader 信息 */}
|
{/* 盈亏区域 - 常驻显示 */}
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Leader</div>
|
padding: '8px 12px',
|
||||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
backgroundColor: '#fafafa',
|
||||||
{record.leaderName || `Leader ${record.leaderId}`}
|
borderBottom: '1px solid #f0f0f0',
|
||||||
</div>
|
minHeight: '42px',
|
||||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}>
|
display: 'flex',
|
||||||
{record.leaderAddress.slice(0, 6)}...{record.leaderAddress.slice(-4)}
|
alignItems: 'center'
|
||||||
</div>
|
}}>
|
||||||
</div>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||||
|
<div>
|
||||||
{/* 总盈亏 */}
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
|
||||||
{stats && (
|
{t('copyTradingList.totalPnl') || '总盈亏'}
|
||||||
<div style={{ marginBottom: '12px' }}>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>总盈亏</div>
|
{stats ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '16px',
|
fontSize: '14px',
|
||||||
fontWeight: 'bold',
|
fontWeight: '600',
|
||||||
color: getPnlColor(stats.totalPnl),
|
color: getPnlColor(stats.totalPnl),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '4px'
|
gap: '4px'
|
||||||
}}>
|
}}>
|
||||||
{getPnlIcon(stats.totalPnl)}
|
{getPnlIcon(stats.totalPnl)}
|
||||||
{formatUSDC(stats.totalPnl)} USDC
|
{formatUSDC(stats.totalPnl)} USDC
|
||||||
|
</div>
|
||||||
|
) : loadingStatistics.has(record.id) ? (
|
||||||
|
<Spin size="small" />
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
{stats && (
|
||||||
fontSize: '12px',
|
<div style={{ textAlign: 'right' }}>
|
||||||
color: getPnlColor(stats.totalPnlPercent),
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
|
||||||
marginTop: '4px'
|
{t('copyTradingList.profitRate') || '收益率'}
|
||||||
}}>
|
</div>
|
||||||
{formatPercent(stats.totalPnlPercent)}
|
<div style={{
|
||||||
</div>
|
fontSize: '12px',
|
||||||
</div>
|
fontWeight: '500',
|
||||||
)}
|
color: getPnlColor(stats.totalPnlPercent)
|
||||||
|
}}>
|
||||||
{loadingStatistics.has(record.id) && (
|
{formatPercent(stats.totalPnlPercent)}
|
||||||
<div style={{ marginBottom: '12px', fontSize: '12px', color: '#999' }}>
|
</div>
|
||||||
加载统计中...
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 创建时间 */}
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
|
||||||
创建时间: {formattedDate}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 账户和Leader信息区域 */}
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
<div style={{
|
||||||
<Button
|
padding: '8px 12px',
|
||||||
type="primary"
|
fontSize: '11px',
|
||||||
size="small"
|
color: '#8c8c8c',
|
||||||
icon={<EditOutlined />}
|
borderBottom: '1px solid #f0f0f0'
|
||||||
onClick={() => {
|
}}>
|
||||||
setEditModalCopyTradingId(record.id.toString())
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '4px' }}>
|
||||||
setEditModalOpen(true)
|
<WalletOutlined style={{ fontSize: '12px', marginRight: '4px', color: '#1890ff' }} />
|
||||||
}}
|
<span>{t('copyTradingList.wallet') || '账户'}: {record.accountName || `#${record.accountId}`}</span>
|
||||||
style={{ flex: 1, minWidth: '80px' }}
|
</div>
|
||||||
>
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
{t('common.edit') || '编辑'}
|
<UserOutlined style={{ fontSize: '12px', marginRight: '4px', color: '#722ed1' }} />
|
||||||
</Button>
|
<span>Leader: {record.leaderName || `#${record.leaderId}`}</span>
|
||||||
<Button
|
</div>
|
||||||
size="small"
|
</div>
|
||||||
icon={<BarChartOutlined />}
|
|
||||||
onClick={() => {
|
{/* 图标操作栏 */}
|
||||||
setStatisticsModalCopyTradingId(record.id.toString())
|
<div style={{
|
||||||
setStatisticsModalOpen(true)
|
padding: '8px 12px',
|
||||||
}}
|
display: 'flex',
|
||||||
style={{ flex: 1, minWidth: '80px' }}
|
justifyContent: 'space-around',
|
||||||
>
|
alignItems: 'center'
|
||||||
{t('copyTradingList.statistics') || '统计'}
|
}}>
|
||||||
</Button>
|
<Tooltip title={t('common.edit') || '编辑'}>
|
||||||
<Dropdown
|
<div
|
||||||
menu={{
|
onClick={() => {
|
||||||
items: [
|
setEditModalCopyTradingId(record.id.toString())
|
||||||
{
|
setEditModalOpen(true)
|
||||||
key: 'matchedOrders',
|
}}
|
||||||
label: t('copyTradingList.matchedOrders') || '已成交订单',
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
|
||||||
icon: <UnorderedListOutlined />,
|
|
||||||
onClick: () => {
|
|
||||||
setOrdersModalCopyTradingId(record.id.toString())
|
|
||||||
setOrdersModalTab('buy')
|
|
||||||
setOrdersModalOpen(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'filteredOrders',
|
|
||||||
label: t('copyTradingList.filteredOrders') || '已过滤订单',
|
|
||||||
icon: <UnorderedListOutlined />,
|
|
||||||
onClick: () => {
|
|
||||||
setFilteredOrdersModalCopyTradingId(record.id.toString())
|
|
||||||
setFilteredOrdersModalOpen(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}}
|
|
||||||
trigger={['click']}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<UnorderedListOutlined />}
|
|
||||||
style={{ flex: 1, minWidth: '80px' }}
|
|
||||||
>
|
>
|
||||||
{t('copyTradingList.orders') || '订单'}
|
<EditOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
</Button>
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.edit') || '编辑'}</span>
|
||||||
</Dropdown>
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('copyTradingList.statistics') || '统计'}>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setStatisticsModalCopyTradingId(record.id.toString())
|
||||||
|
setStatisticsModalOpen(true)
|
||||||
|
}}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
<BarChartOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('copyTradingList.statistics') || '统计'}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('copyTradingList.orders') || '订单'}>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setOrdersModalCopyTradingId(record.id.toString())
|
||||||
|
setOrdersModalTab('buy')
|
||||||
|
setOrdersModalOpen(true)
|
||||||
|
}}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
<UnorderedListOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('copyTradingList.orders') || '订单'}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t('copyTradingList.deleteConfirm') || '确定要删除这个跟单关系吗?'}
|
title={t('copyTradingList.deleteConfirm') || '确定要删除这个跟单关系吗?'}
|
||||||
onConfirm={() => handleDelete(record.id)}
|
onConfirm={() => handleDelete(record.id)}
|
||||||
okText={t('common.confirm') || '确定'}
|
okText={t('common.confirm') || '确定'}
|
||||||
cancelText={t('common.cancel') || '取消'}
|
cancelText={t('common.cancel') || '取消'}
|
||||||
>
|
>
|
||||||
<Button
|
<Tooltip title={t('common.delete') || '删除'}>
|
||||||
danger
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
size="small"
|
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
|
||||||
icon={<DeleteOutlined />}
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete') || '删除'}</span>
|
||||||
style={{ flex: 1, minWidth: '80px' }}
|
</div>
|
||||||
>
|
</Tooltip>
|
||||||
{t('common.delete') || '删除'}
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
}}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { Modal, Row, Col, Statistic, Button, Space, Empty } from 'antd'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import type { EChartsOption } from 'echarts'
|
||||||
|
import { formatUSDC } from '../utils'
|
||||||
|
import type { CryptoTailPnlCurveResponse } from '../types'
|
||||||
|
|
||||||
|
export interface CryptoTailPnlCurveModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
data: CryptoTailPnlCurveResponse | null
|
||||||
|
loading: boolean
|
||||||
|
strategyName: string
|
||||||
|
preset: 'today' | '7d' | '30d' | 'all'
|
||||||
|
onPresetChange: (preset: 'today' | '7d' | '30d' | 'all') => void
|
||||||
|
onRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CryptoTailPnlCurveModal: React.FC<CryptoTailPnlCurveModalProps> = (props) => {
|
||||||
|
const { open, onClose, data, loading, strategyName, preset, onPresetChange, onRefresh } = props
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const chartInstance = useRef<echarts.ECharts | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !data?.curveData?.length || !chartRef.current) return
|
||||||
|
if (chartInstance.current) {
|
||||||
|
const dom = chartInstance.current.getDom()
|
||||||
|
if (!dom || !document.contains(dom)) {
|
||||||
|
chartInstance.current.dispose()
|
||||||
|
chartInstance.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!chartInstance.current) chartInstance.current = echarts.init(chartRef.current)
|
||||||
|
const option: EChartsOption = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: unknown) => {
|
||||||
|
const arr = params as Array<{ value: [number, number] }>
|
||||||
|
const v = arr[0]?.value
|
||||||
|
if (!v) return ''
|
||||||
|
const d = data.curveData.find((p) => p.timestamp === v[0])
|
||||||
|
if (!d) return ''
|
||||||
|
return dayjs(v[0]).format('YYYY-MM-DD HH:mm') + '<br/>' + t('cryptoTailStrategy.pnlCurve.totalPnl') + ': ' + formatUSDC(d.cumulativePnl) + ' USDC'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', top: '10%', containLabel: true },
|
||||||
|
xAxis: { type: 'time' },
|
||||||
|
yAxis: { type: 'value', axisLabel: { formatter: (val: number) => String(val) + ' USDC' } },
|
||||||
|
series: [{
|
||||||
|
name: t('cryptoTailStrategy.pnlCurve.totalPnl'),
|
||||||
|
type: 'line',
|
||||||
|
data: data.curveData.map((p) => [p.timestamp, parseFloat(p.cumulativePnl)]),
|
||||||
|
smooth: preset === 'all',
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 4,
|
||||||
|
lineStyle: { width: 2, color: '#1890ff' },
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(24, 144, 255, 0.3)' },
|
||||||
|
{ offset: 1, color: 'rgba(24, 144, 255, 0.05)' }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
chartInstance.current.setOption(option, true)
|
||||||
|
chartInstance.current.resize()
|
||||||
|
}, [open, data, preset, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
chartInstance.current?.dispose()
|
||||||
|
chartInstance.current = null
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !chartInstance.current) return
|
||||||
|
const handleResize = () => chartInstance.current?.resize()
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
return () => window.removeEventListener('resize', handleResize)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const pnlColor = (value: string | null | undefined): string | undefined => {
|
||||||
|
if (value == null || value === '') return undefined
|
||||||
|
const num = parseFloat(value)
|
||||||
|
if (Number.isNaN(num)) return undefined
|
||||||
|
if (num > 0) return '#52c41a'
|
||||||
|
if (num < 0) return '#ff4d4f'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('cryptoTailStrategy.pnlCurve.title') + ' - ' + strategyName}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={Math.min(800, window.innerWidth - 48)}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic
|
||||||
|
title={t('cryptoTailStrategy.pnlCurve.totalPnl')}
|
||||||
|
value={data?.totalRealizedPnl != null ? formatUSDC(data.totalRealizedPnl) : '-'}
|
||||||
|
suffix="USDC"
|
||||||
|
valueStyle={{ color: pnlColor(data?.totalRealizedPnl ?? null) }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic title={t('cryptoTailStrategy.pnlCurve.settledCount')} value={data?.settledCount ?? 0} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic
|
||||||
|
title={t('cryptoTailStrategy.pnlCurve.winRate')}
|
||||||
|
value={data?.winRate != null ? (Number(data.winRate) * 100).toFixed(1) + '%' : '-'}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic
|
||||||
|
title={t('cryptoTailStrategy.pnlCurve.maxDrawdown')}
|
||||||
|
value={data?.maxDrawdown != null ? '-' + formatUSDC(data.maxDrawdown) : '-'}
|
||||||
|
suffix="USDC"
|
||||||
|
valueStyle={{ color: data?.maxDrawdown ? '#ff4d4f' : undefined }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Space wrap style={{ marginBottom: 16 }}>
|
||||||
|
<Button size="small" type={preset === 'today' ? 'primary' : 'default'} onClick={() => onPresetChange('today')}>{t('cryptoTailStrategy.pnlCurve.today')}</Button>
|
||||||
|
<Button size="small" type={preset === '7d' ? 'primary' : 'default'} onClick={() => onPresetChange('7d')}>{t('cryptoTailStrategy.pnlCurve.last7Days')}</Button>
|
||||||
|
<Button size="small" type={preset === '30d' ? 'primary' : 'default'} onClick={() => onPresetChange('30d')}>{t('cryptoTailStrategy.pnlCurve.last30Days')}</Button>
|
||||||
|
<Button size="small" type={preset === 'all' ? 'primary' : 'default'} onClick={() => onPresetChange('all')}>{t('cryptoTailStrategy.pnlCurve.all')}</Button>
|
||||||
|
<Button size="small" onClick={onRefresh} loading={loading}>{t('common.refresh')}</Button>
|
||||||
|
</Space>
|
||||||
|
{!data && loading
|
||||||
|
? <div style={{ height: 320, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{t('common.loading')}</div>
|
||||||
|
: !data?.curveData?.length
|
||||||
|
? <Empty description={t('cryptoTailStrategy.pnlCurve.empty')} style={{ marginTop: 24 }} />
|
||||||
|
: <div ref={chartRef} style={{ width: '100%', height: isMobile ? 260 : 320 }} />}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CryptoTailPnlCurveModal
|
||||||
@@ -21,21 +21,22 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Empty,
|
Empty,
|
||||||
Typography,
|
Typography
|
||||||
Divider
|
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { Dayjs } from 'dayjs'
|
import type { Dayjs } from 'dayjs'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
|
import { PlusOutlined, EditOutlined, UnorderedListOutlined, LineChartOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import { useAccountStore } from '../store/accountStore'
|
import { useAccountStore } from '../store/accountStore'
|
||||||
import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto } from '../types'
|
import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto, CryptoTailPnlCurveResponse } from '../types'
|
||||||
import { formatUSDC, formatNumber } from '../utils'
|
import { formatUSDC, formatNumber } from '../utils'
|
||||||
|
import { getVersionInfo } from '../utils/version'
|
||||||
|
import CryptoTailPnlCurveModal from './CryptoTailPnlCurveModal'
|
||||||
|
|
||||||
const CryptoTailStrategyList: React.FC = () => {
|
const CryptoTailStrategyList: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
const { accounts, fetchAccounts } = useAccountStore()
|
const { accounts, fetchAccounts } = useAccountStore()
|
||||||
@@ -58,6 +59,14 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
const [triggersLoading, setTriggersLoading] = useState(false)
|
const [triggersLoading, setTriggersLoading] = useState(false)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const [pnlCurveModalOpen, setPnlCurveModalOpen] = useState(false)
|
||||||
|
const [pnlCurveStrategyId, setPnlCurveStrategyId] = useState<number | null>(null)
|
||||||
|
const [pnlCurveStrategyName, setPnlCurveStrategyName] = useState('')
|
||||||
|
const [pnlCurvePreset, setPnlCurvePreset] = useState<'today' | '7d' | '30d' | 'all'>('all')
|
||||||
|
const [pnlCurveCustomRange, setPnlCurveCustomRange] = useState<[Dayjs | null, Dayjs | null]>([null, null])
|
||||||
|
const [pnlCurveData, setPnlCurveData] = useState<CryptoTailPnlCurveResponse | null>(null)
|
||||||
|
const [pnlCurveLoading, setPnlCurveLoading] = useState(false)
|
||||||
|
|
||||||
/** 币安 API 健康状态:仅保留「不可用」的项,用于强提醒 */
|
/** 币安 API 健康状态:仅保留「不可用」的项,用于强提醒 */
|
||||||
const [binanceUnhealthy, setBinanceUnhealthy] = useState<Array<{ name: string; message: string }>>([])
|
const [binanceUnhealthy, setBinanceUnhealthy] = useState<Array<{ name: string; message: string }>>([])
|
||||||
const [binanceCheckLoading, setBinanceCheckLoading] = useState(false)
|
const [binanceCheckLoading, setBinanceCheckLoading] = useState(false)
|
||||||
@@ -148,7 +157,8 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
amountMode: 'RATIO',
|
amountMode: 'RATIO',
|
||||||
maxPrice: '1',
|
maxPrice: '1',
|
||||||
minSpreadMode: 'AUTO',
|
spreadMode: 'AUTO',
|
||||||
|
spreadDirection: 'MIN',
|
||||||
windowStartMinutes: 0,
|
windowStartMinutes: 0,
|
||||||
windowStartSeconds: 0
|
windowStartSeconds: 0
|
||||||
})
|
})
|
||||||
@@ -169,8 +179,9 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
maxPrice: record.maxPrice,
|
maxPrice: record.maxPrice,
|
||||||
amountMode: record.amountMode,
|
amountMode: record.amountMode,
|
||||||
amountValue: record.amountValue,
|
amountValue: record.amountValue,
|
||||||
minSpreadMode: record.minSpreadMode ?? 'AUTO',
|
spreadMode: record.spreadMode ?? 'AUTO',
|
||||||
minSpreadValue: record.minSpreadValue ?? undefined,
|
spreadValue: record.spreadValue ?? undefined,
|
||||||
|
spreadDirection: record.spreadDirection ?? 'MIN',
|
||||||
enabled: record.enabled
|
enabled: record.enabled
|
||||||
})
|
})
|
||||||
setFormModalOpen(true)
|
setFormModalOpen(true)
|
||||||
@@ -203,8 +214,9 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
maxPrice: v.maxPrice != null ? String(v.maxPrice) : undefined,
|
maxPrice: v.maxPrice != null ? String(v.maxPrice) : undefined,
|
||||||
amountMode: v.amountMode as string,
|
amountMode: v.amountMode as string,
|
||||||
amountValue: String(v.amountValue ?? 0),
|
amountValue: String(v.amountValue ?? 0),
|
||||||
minSpreadMode: (v.minSpreadMode as string) || 'AUTO',
|
spreadMode: (v.spreadMode as string) || 'AUTO',
|
||||||
minSpreadValue: v.minSpreadMode === 'FIXED' && v.minSpreadValue != null ? String(v.minSpreadValue) : (v.minSpreadMode === 'AUTO' && v.minSpreadValue != null ? String(v.minSpreadValue) : undefined),
|
spreadValue: v.spreadMode === 'FIXED' && v.spreadValue != null ? String(v.spreadValue) : (v.spreadMode === 'AUTO' && v.spreadValue != null ? String(v.spreadValue) : undefined),
|
||||||
|
spreadDirection: v.spreadDirection as string || 'MIN',
|
||||||
enabled: v.enabled !== false
|
enabled: v.enabled !== false
|
||||||
}
|
}
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
@@ -217,8 +229,9 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
maxPrice: payload.maxPrice,
|
maxPrice: payload.maxPrice,
|
||||||
amountMode: payload.amountMode,
|
amountMode: payload.amountMode,
|
||||||
amountValue: payload.amountValue,
|
amountValue: payload.amountValue,
|
||||||
minSpreadMode: payload.minSpreadMode,
|
spreadMode: payload.spreadMode,
|
||||||
minSpreadValue: payload.minSpreadValue,
|
spreadValue: payload.spreadValue,
|
||||||
|
spreadDirection: payload.spreadDirection,
|
||||||
enabled: payload.enabled
|
enabled: payload.enabled
|
||||||
})
|
})
|
||||||
if (res.data.code === 0) {
|
if (res.data.code === 0) {
|
||||||
@@ -231,7 +244,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
const res = await apiService.cryptoTailStrategy.create({
|
const res = await apiService.cryptoTailStrategy.create({
|
||||||
...payload,
|
...payload,
|
||||||
minSpreadValue: payload.minSpreadMode === 'FIXED' ? payload.minSpreadValue : undefined
|
spreadValue: payload.spreadMode === 'FIXED' ? payload.spreadValue : undefined
|
||||||
})
|
})
|
||||||
if (res.data.code === 0) {
|
if (res.data.code === 0) {
|
||||||
message.success(t('common.success'))
|
message.success(t('common.success'))
|
||||||
@@ -323,6 +336,61 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
await loadTriggerRecords(strategyId, 'success', { page: 1, pageSize: 20, dateRange: [null, null] })
|
await loadTriggerRecords(strategyId, 'success', { page: 1, pageSize: 20, dateRange: [null, null] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPnlCurveTimeRange = (): { startDate?: number; endDate?: number } => {
|
||||||
|
if (pnlCurvePreset === 'all') return {}
|
||||||
|
const now = dayjs()
|
||||||
|
if (pnlCurvePreset === 'today') {
|
||||||
|
return { startDate: now.startOf('day').valueOf(), endDate: now.valueOf() }
|
||||||
|
}
|
||||||
|
if (pnlCurvePreset === '7d') {
|
||||||
|
return { startDate: now.subtract(7, 'day').startOf('day').valueOf(), endDate: now.valueOf() }
|
||||||
|
}
|
||||||
|
if (pnlCurvePreset === '30d') {
|
||||||
|
return { startDate: now.subtract(30, 'day').startOf('day').valueOf(), endDate: now.valueOf() }
|
||||||
|
}
|
||||||
|
if (pnlCurveCustomRange[0] != null && pnlCurveCustomRange[1] != null) {
|
||||||
|
return {
|
||||||
|
startDate: pnlCurveCustomRange[0].startOf('day').valueOf(),
|
||||||
|
endDate: pnlCurveCustomRange[1].endOf('day').valueOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPnlCurve = async () => {
|
||||||
|
if (pnlCurveStrategyId == null) return
|
||||||
|
setPnlCurveLoading(true)
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = getPnlCurveTimeRange()
|
||||||
|
const res = await apiService.cryptoTailStrategy.pnlCurve({
|
||||||
|
strategyId: pnlCurveStrategyId,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
})
|
||||||
|
if (res.data.code === 0 && res.data.data) {
|
||||||
|
setPnlCurveData(res.data.data)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load PnL curve:', e)
|
||||||
|
} finally {
|
||||||
|
setPnlCurveLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPnlCurve = (record: CryptoTailStrategyDto) => {
|
||||||
|
setPnlCurveStrategyId(record.id)
|
||||||
|
setPnlCurveStrategyName(record.name ?? record.marketTitle ?? record.marketSlugPrefix ?? '')
|
||||||
|
setPnlCurvePreset('all')
|
||||||
|
setPnlCurveCustomRange([null, null])
|
||||||
|
setPnlCurveModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pnlCurveModalOpen && pnlCurveStrategyId != null) {
|
||||||
|
loadPnlCurve()
|
||||||
|
}
|
||||||
|
}, [pnlCurveModalOpen, pnlCurveStrategyId, pnlCurvePreset, pnlCurveCustomRange])
|
||||||
|
|
||||||
const onTriggerTabChange = (key: string) => {
|
const onTriggerTabChange = (key: string) => {
|
||||||
const next = key === 'success' ? 'success' : 'fail'
|
const next = key === 'success' ? 'success' : 'fail'
|
||||||
setTriggerTab(next)
|
setTriggerTab(next)
|
||||||
@@ -384,7 +452,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
title: t('common.status'),
|
title: t('common.status'),
|
||||||
dataIndex: 'enabled',
|
dataIndex: 'enabled',
|
||||||
key: 'enabled',
|
key: 'enabled',
|
||||||
width: 80,
|
width: 72,
|
||||||
render: (enabled: boolean, record: CryptoTailStrategyDto) => (
|
render: (enabled: boolean, record: CryptoTailStrategyDto) => (
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
@@ -397,7 +465,8 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
title: t('cryptoTailStrategy.list.strategyName'),
|
title: t('cryptoTailStrategy.list.strategyName'),
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
key: 'name',
|
key: 'name',
|
||||||
width: isMobile ? 100 : 160,
|
width: isMobile ? 100 : 140,
|
||||||
|
ellipsis: true,
|
||||||
render: (name: string | undefined, r: CryptoTailStrategyDto) => (
|
render: (name: string | undefined, r: CryptoTailStrategyDto) => (
|
||||||
<Typography.Text strong style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
<Typography.Text strong style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||||
{name || (r.marketTitle ?? r.marketSlugPrefix) || '-'}
|
{name || (r.marketTitle ?? r.marketSlugPrefix) || '-'}
|
||||||
@@ -408,7 +477,8 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
title: t('cryptoTailStrategy.list.account'),
|
title: t('cryptoTailStrategy.list.account'),
|
||||||
dataIndex: 'accountId',
|
dataIndex: 'accountId',
|
||||||
key: 'accountId',
|
key: 'accountId',
|
||||||
width: isMobile ? 90 : 120,
|
width: isMobile ? 90 : 100,
|
||||||
|
ellipsis: true,
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
||||||
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||||
{getAccountLabel(r.accountId)}
|
{getAccountLabel(r.accountId)}
|
||||||
@@ -416,99 +486,149 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.list.market'),
|
title: t('cryptoTailStrategy.list.marketAndTime'),
|
||||||
key: 'market',
|
key: 'marketAndTime',
|
||||||
width: isMobile ? 120 : 200,
|
width: isMobile ? 120 : 180,
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
||||||
<Typography.Text style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
<div>
|
||||||
{marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'}
|
<Typography.Text style={{ wordBreak: 'break-word', whiteSpace: 'normal', display: 'block' }}>
|
||||||
</Typography.Text>
|
{marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'pre-line' }}>
|
||||||
|
{formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds, false)}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.list.timeWindow'),
|
title: t('cryptoTailStrategy.list.config'),
|
||||||
key: 'timeWindow',
|
key: 'config',
|
||||||
width: isMobile ? 100 : 120,
|
width: isMobile ? 100 : 140,
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
||||||
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'pre-line' }}>
|
<div>
|
||||||
{formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds)}
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
|
||||||
</Typography.Text>
|
{formatPriceRange(r.minPrice, r.maxPrice)}
|
||||||
)
|
</Typography.Text>
|
||||||
},
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{
|
{(r.amountMode?.toUpperCase() ?? '') === 'RATIO'
|
||||||
title: t('cryptoTailStrategy.list.priceRange'),
|
? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%`
|
||||||
key: 'priceRange',
|
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)}`}
|
||||||
width: isMobile ? 90 : 120,
|
</Typography.Text>
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
</div>
|
||||||
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
|
||||||
{formatPriceRange(r.minPrice, r.maxPrice)}
|
|
||||||
</Typography.Text>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('cryptoTailStrategy.list.amountMode'),
|
|
||||||
key: 'amountMode',
|
|
||||||
width: isMobile ? 90 : 120,
|
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
|
||||||
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
|
||||||
{(r.amountMode?.toUpperCase() ?? '') === 'RATIO'
|
|
||||||
? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%`
|
|
||||||
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)} USDC`}
|
|
||||||
</Typography.Text>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.list.totalRealizedPnl'),
|
title: t('cryptoTailStrategy.list.totalRealizedPnl'),
|
||||||
key: 'totalRealizedPnl',
|
key: 'pnl',
|
||||||
width: isMobile ? 90 : 120,
|
width: isMobile ? 90 : 120,
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => {
|
render: (_: unknown, r: CryptoTailStrategyDto) => {
|
||||||
const text = r.totalRealizedPnl != null ? `${formatUSDC(r.totalRealizedPnl)} USDC` : '-'
|
const text = r.totalRealizedPnl != null ? formatUSDC(r.totalRealizedPnl) : '-'
|
||||||
const color = pnlColor(r.totalRealizedPnl)
|
const color = pnlColor(r.totalRealizedPnl)
|
||||||
return color ? (
|
return (
|
||||||
<Typography.Text style={{ color, fontWeight: 500 }}>{text}</Typography.Text>
|
<div>
|
||||||
) : (
|
{color ? (
|
||||||
<Typography.Text type="secondary">{text}</Typography.Text>
|
<Typography.Text style={{ color, fontWeight: 500 }}>{text}</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">{text}</Typography.Text>
|
||||||
|
)}
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
|
||||||
|
{r.winRate != null ? `${(Number(r.winRate) * 100).toFixed(1)}%` : '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t('cryptoTailStrategy.list.winRate'),
|
|
||||||
key: 'winRate',
|
|
||||||
width: isMobile ? 70 : 90,
|
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) =>
|
|
||||||
r.winRate != null ? (
|
|
||||||
<Tag color="blue">{(Number(r.winRate) * 100).toFixed(1)}%</Tag>
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">-</Typography.Text>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.list.actions'),
|
title: t('cryptoTailStrategy.list.actions'),
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: isMobile ? 120 : 200,
|
width: isMobile ? 120 : 140,
|
||||||
fixed: 'right' as const,
|
fixed: 'right' as const,
|
||||||
render: (_: unknown, record: CryptoTailStrategyDto) => (
|
render: (_: unknown, record: CryptoTailStrategyDto) => (
|
||||||
<Space size="small" wrap>
|
<Space size={4}>
|
||||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
|
<Tooltip title={t('cryptoTailStrategy.list.edit')}>
|
||||||
{t('cryptoTailStrategy.list.edit')}
|
<div
|
||||||
</Button>
|
onClick={() => openEditModal(record)}
|
||||||
<Button
|
style={{
|
||||||
type="link"
|
display: 'flex',
|
||||||
size="small"
|
alignItems: 'center',
|
||||||
icon={<UnorderedListOutlined />}
|
justifyContent: 'center',
|
||||||
onClick={() => openTriggers(record.id)}
|
width: '32px',
|
||||||
>
|
height: '32px',
|
||||||
{t('cryptoTailStrategy.list.viewTriggers')}
|
cursor: 'pointer',
|
||||||
</Button>
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
|
>
|
||||||
|
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('cryptoTailStrategy.list.viewTriggers')}>
|
||||||
|
<div
|
||||||
|
onClick={() => openTriggers(record.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
|
>
|
||||||
|
<UnorderedListOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('cryptoTailStrategy.list.viewPnlCurve')}>
|
||||||
|
<div
|
||||||
|
onClick={() => openPnlCurve(record)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
|
>
|
||||||
|
<LineChartOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t('cryptoTailStrategy.list.deleteConfirm')}
|
title={t('cryptoTailStrategy.list.deleteConfirm')}
|
||||||
onConfirm={() => handleDelete(record.id)}
|
onConfirm={() => handleDelete(record.id)}
|
||||||
okText={t('common.confirm')}
|
okText={t('common.confirm')}
|
||||||
cancelText={t('common.cancel')}
|
cancelText={t('common.cancel')}
|
||||||
>
|
>
|
||||||
<Button type="link" size="small" danger>
|
<Tooltip title={t('cryptoTailStrategy.list.delete')}>
|
||||||
{t('cryptoTailStrategy.list.delete')}
|
<div
|
||||||
</Button>
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#fff1f0' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
|
>
|
||||||
|
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
@@ -531,10 +651,37 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
})
|
})
|
||||||
}, [formModalOpen, editingId, selectedMarket, intervalSeconds])
|
}, [formModalOpen, editingId, selectedMarket, intervalSeconds])
|
||||||
|
|
||||||
|
const getGuideUrl = () => {
|
||||||
|
const { githubRepoUrl } = getVersionInfo()
|
||||||
|
const lang = i18n.language === 'zh-CN' || i18n.language === 'zh-TW' ? 'zh' : 'en'
|
||||||
|
return `${githubRepoUrl}/blob/main/docs/crypto-tail-strategy/${lang}/crypto-tail-strategy-user-guide.md`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: isMobile ? 12 : 24 }}>
|
<div style={{ padding: isMobile ? 0 : 24 }}>
|
||||||
<h1 style={{ marginBottom: 16, fontSize: isMobile ? 20 : 24 }}>{t('cryptoTailStrategy.list.title')}</h1>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px', padding: isMobile ? '0 8px' : 0 }}>
|
||||||
{binanceUnhealthy.length > 0 && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('cryptoTailStrategy.list.title')}</h2>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<FileTextOutlined />}
|
||||||
|
onClick={() => window.open(getGuideUrl(), '_blank')}
|
||||||
|
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 14 : 16 }}
|
||||||
|
>
|
||||||
|
{t('cryptoTailStrategy.list.configGuide')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Tooltip title={t('cryptoTailStrategy.list.addStrategy')}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={openAddModal}
|
||||||
|
size={isMobile ? 'middle' : 'large'}
|
||||||
|
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{binanceUnhealthy.length > 0 && list.some((s) => s.enabled) && (
|
||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
@@ -562,20 +709,17 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16, margin: isMobile ? '0 8px 16px' : undefined }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
message={t('cryptoTailStrategy.list.walletTip')}
|
message={t('cryptoTailStrategy.list.walletTip')}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16, margin: isMobile ? '0 8px 16px' : undefined }}
|
||||||
/>
|
/>
|
||||||
<Card>
|
<Card style={{ borderRadius: isMobile ? 0 : '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: isMobile ? 'none' : '1px solid #e8e8e8' }} styles={{ body: { padding: isMobile ? 12 : 24 } }}>
|
||||||
<div style={{ marginBottom: 16, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
<div style={{ marginBottom: 16, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>
|
|
||||||
{t('cryptoTailStrategy.list.addStrategy')}
|
|
||||||
</Button>
|
|
||||||
<Select
|
<Select
|
||||||
placeholder={t('cryptoTailStrategy.form.selectAccount')}
|
placeholder={t('cryptoTailStrategy.form.selectAccount')}
|
||||||
allowClear
|
allowClear
|
||||||
@@ -598,86 +742,86 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{list.map((item) => (
|
{list.map((item) => {
|
||||||
<Card
|
const accountLabel = getAccountLabel(item.accountId)
|
||||||
key={item.id}
|
const marketTitle = marketOptions.find((m) => m.slug === item.marketSlugPrefix)?.title ?? item.marketTitle ?? item.marketSlugPrefix ?? '-'
|
||||||
size="small"
|
return (
|
||||||
styles={{ body: { padding: 16 } }}
|
<Card
|
||||||
style={{ borderLeft: `3px solid ${item.enabled ? 'var(--ant-colorSuccess)' : 'var(--ant-colorBorder)'}` }}
|
key={item.id}
|
||||||
>
|
style={{
|
||||||
<Typography.Text strong style={{ fontSize: 15, wordBreak: 'break-word', whiteSpace: 'normal', display: 'block', marginBottom: 8 }}>
|
marginBottom: 0,
|
||||||
{item.name || (item.marketTitle ?? item.marketSlugPrefix) || '-'}
|
borderRadius: '10px',
|
||||||
</Typography.Text>
|
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 4, fontSize: 13, wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
border: '1px solid #e8e8e8',
|
||||||
{t('cryptoTailStrategy.list.account')}: {getAccountLabel(item.accountId)}
|
overflow: 'hidden'
|
||||||
</Typography.Text>
|
}}
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8, fontSize: 13, wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
bodyStyle={{ padding: 0 }}
|
||||||
{marketOptions.find((m) => m.slug === item.marketSlugPrefix)?.title ?? item.marketSlugPrefix ?? '-'}
|
>
|
||||||
</Typography.Text>
|
<div style={{
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 16px', marginBottom: 12 }}>
|
padding: '10px 12px',
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
background: item.enabled ? 'var(--ant-color-primary, #1677ff)' : 'var(--ant-color-fill-secondary, #f0f0f0)',
|
||||||
{t('cryptoTailStrategy.list.timeWindow')}
|
color: item.enabled ? '#fff' : 'var(--ant-color-text-secondary, #666)'
|
||||||
</Typography.Text>
|
}}>
|
||||||
<Typography.Text style={{ fontSize: 12 }}>{formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds, false)}</Typography.Text>
|
<div style={{ fontSize: '15px', fontWeight: '600', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
<span style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>{item.name || marketTitle || '-'}</span>
|
||||||
{t('cryptoTailStrategy.list.priceRange')}
|
<Switch checked={item.enabled} onChange={() => handleToggle(item)} size="small" />
|
||||||
</Typography.Text>
|
</div>
|
||||||
<Typography.Text style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>{formatPriceRange(item.minPrice, item.maxPrice)}</Typography.Text>
|
</div>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
<div style={{ padding: '8px 12px', backgroundColor: '#fafafa', borderBottom: '1px solid #f0f0f0' }}>
|
||||||
{t('cryptoTailStrategy.list.amountMode')}
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
</Typography.Text>
|
<div>
|
||||||
<Typography.Text style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('cryptoTailStrategy.list.totalRealizedPnl')}</div>
|
||||||
{(item.amountMode?.toUpperCase() ?? '') === 'RATIO'
|
{item.totalRealizedPnl != null ? (
|
||||||
? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(item.amountValue, 2) || '0'}%`
|
<div style={{ fontSize: '14px', fontWeight: '600', color: pnlColor(item.totalRealizedPnl) }}>{formatUSDC(item.totalRealizedPnl)} USDC</div>
|
||||||
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(item.amountValue)} USDC`}
|
) : (
|
||||||
</Typography.Text>
|
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
|
||||||
</div>
|
)}
|
||||||
<Divider style={{ margin: '10px 0' }} />
|
</div>
|
||||||
<Space wrap style={{ marginBottom: 12 }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('cryptoTailStrategy.list.winRate')}</div>
|
||||||
{t('cryptoTailStrategy.list.totalRealizedPnl')}:{' '}
|
{item.winRate != null ? <Tag color="blue" style={{ margin: 0 }}>{(Number(item.winRate) * 100).toFixed(1)}%</Tag> : <span style={{ fontSize: '12px', color: '#8c8c8c' }}>-</span>}
|
||||||
</Typography.Text>
|
</div>
|
||||||
{item.totalRealizedPnl != null ? (
|
</div>
|
||||||
<Typography.Text style={{ color: pnlColor(item.totalRealizedPnl) ?? undefined, fontWeight: 500, fontSize: 12 }}>
|
</div>
|
||||||
{formatUSDC(item.totalRealizedPnl)} USDC
|
<div style={{ padding: '6px 12px', fontSize: '11px', color: '#8c8c8c', borderBottom: '1px solid #f0f0f0' }}>
|
||||||
</Typography.Text>
|
<div>{t('cryptoTailStrategy.list.account')}: {accountLabel}</div>
|
||||||
) : (
|
<div>{t('cryptoTailStrategy.list.market')}: {marketTitle}</div>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>-</Typography.Text>
|
</div>
|
||||||
)}
|
<div style={{ padding: '6px 12px', fontSize: '11px', color: '#8c8c8c', borderBottom: '1px solid #f0f0f0' }}>
|
||||||
{item.winRate != null && (
|
{t('cryptoTailStrategy.list.timeWindow')}: {formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds, false)} · {t('cryptoTailStrategy.list.priceRange')}: {formatPriceRange(item.minPrice, item.maxPrice)}
|
||||||
<>
|
</div>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>·</Typography.Text>
|
<div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-around', alignItems: 'center' }}>
|
||||||
<Tag color="blue" style={{ margin: 0 }}>{t('cryptoTailStrategy.list.winRate')} {(Number(item.winRate) * 100).toFixed(1)}%</Tag>
|
<Tooltip title={t('cryptoTailStrategy.list.edit')}>
|
||||||
</>
|
<div onClick={() => openEditModal(item)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
)}
|
<EditOutlined style={{ fontSize: '18px', color: '#52c41a' }} />
|
||||||
</Space>
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.edit')}</span>
|
||||||
<Divider style={{ margin: '10px 0' }} />
|
</div>
|
||||||
<Space wrap size="small">
|
</Tooltip>
|
||||||
<Switch
|
<Tooltip title={t('cryptoTailStrategy.list.viewTriggers')}>
|
||||||
checked={item.enabled}
|
<div onClick={() => openTriggers(item.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
onChange={() => handleToggle(item)}
|
<UnorderedListOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
size="small"
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.viewTriggers')}</span>
|
||||||
/>
|
</div>
|
||||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(item)}>
|
</Tooltip>
|
||||||
{t('cryptoTailStrategy.list.edit')}
|
<Tooltip title={t('cryptoTailStrategy.list.viewPnlCurve')}>
|
||||||
</Button>
|
<div onClick={() => openPnlCurve(item)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
<Button type="link" size="small" icon={<UnorderedListOutlined />} onClick={() => openTriggers(item.id)}>
|
<LineChartOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
{t('cryptoTailStrategy.list.viewTriggers')}
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.viewPnlCurve')}</span>
|
||||||
</Button>
|
</div>
|
||||||
<Popconfirm
|
</Tooltip>
|
||||||
title={t('cryptoTailStrategy.list.deleteConfirm')}
|
<Popconfirm title={t('cryptoTailStrategy.list.deleteConfirm')} onConfirm={() => handleDelete(item.id)} okText={t('common.confirm')} cancelText={t('common.cancel')}>
|
||||||
onConfirm={() => handleDelete(item.id)}
|
<Tooltip title={t('cryptoTailStrategy.list.delete')}>
|
||||||
okText={t('common.confirm')}
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
cancelText={t('common.cancel')}
|
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
|
||||||
>
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.delete')}</span>
|
||||||
<Button type="link" size="small" danger>
|
</div>
|
||||||
{t('cryptoTailStrategy.list.delete')}
|
</Tooltip>
|
||||||
</Button>
|
</Popconfirm>
|
||||||
</Popconfirm>
|
</div>
|
||||||
</Space>
|
</Card>
|
||||||
</Card>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table
|
<Table
|
||||||
@@ -685,7 +829,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={list}
|
dataSource={list}
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{ pageSize: 20 }}
|
||||||
scroll={{ x: 900 }}
|
scroll={{ x: 720 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
@@ -714,6 +858,17 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
<p>{t('cryptoTailStrategy.redeemRequiredModal.description')}</p>
|
<p>{t('cryptoTailStrategy.redeemRequiredModal.description')}</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<CryptoTailPnlCurveModal
|
||||||
|
open={pnlCurveModalOpen}
|
||||||
|
onClose={() => setPnlCurveModalOpen(false)}
|
||||||
|
data={pnlCurveData}
|
||||||
|
loading={pnlCurveLoading}
|
||||||
|
strategyName={pnlCurveStrategyName}
|
||||||
|
preset={pnlCurvePreset}
|
||||||
|
onPresetChange={setPnlCurvePreset}
|
||||||
|
onRefresh={loadPnlCurve}
|
||||||
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editingId ? t('cryptoTailStrategy.form.update') : t('cryptoTailStrategy.form.create')}
|
title={editingId ? t('cryptoTailStrategy.form.update') : t('cryptoTailStrategy.form.create')}
|
||||||
open={formModalOpen}
|
open={formModalOpen}
|
||||||
@@ -723,7 +878,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
<Alert type="warning" showIcon message={t('cryptoTailStrategy.form.walletTip')} style={{ marginBottom: 16 }} />
|
<Alert type="warning" showIcon message={t('cryptoTailStrategy.form.walletTip')} style={{ marginBottom: 16 }} />
|
||||||
<Form form={form} layout="vertical" initialValues={{ amountMode: 'RATIO', maxPrice: '1', minSpreadMode: 'AUTO', enabled: true }}>
|
<Form form={form} layout="vertical" initialValues={{ amountMode: 'RATIO', maxPrice: '1', spreadMode: 'AUTO', spreadDirection: 'MIN', enabled: true }}>
|
||||||
<Form.Item name="accountId" label={t('cryptoTailStrategy.form.selectAccount')} rules={[{ required: true }]}>
|
<Form.Item name="accountId" label={t('cryptoTailStrategy.form.selectAccount')} rules={[{ required: true }]}>
|
||||||
<Select
|
<Select
|
||||||
placeholder={t('cryptoTailStrategy.form.selectAccount')}
|
placeholder={t('cryptoTailStrategy.form.selectAccount')}
|
||||||
@@ -817,37 +972,37 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="minSpreadMode"
|
name="spreadMode"
|
||||||
label={
|
label={
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<span>{t('cryptoTailStrategy.form.minSpreadMode')}</span>
|
<span>{t('cryptoTailStrategy.form.spreadMode')}</span>
|
||||||
<Tooltip title={t('cryptoTailStrategy.form.minSpreadModeTip')}>
|
<Tooltip title={t('cryptoTailStrategy.form.spreadModeTip')}>
|
||||||
<InfoCircleOutlined style={{ color: '#999', cursor: 'help', fontSize: 14 }} />
|
<InfoCircleOutlined style={{ color: '#999', cursor: 'help', fontSize: 14 }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Radio.Group>
|
<Radio.Group>
|
||||||
<Radio value="AUTO">{t('cryptoTailStrategy.form.minSpreadModeAuto')}</Radio>
|
<Radio value="AUTO">{t('cryptoTailStrategy.form.spreadModeAuto')}</Radio>
|
||||||
<Radio value="FIXED">{t('cryptoTailStrategy.form.minSpreadModeFixed')}</Radio>
|
<Radio value="FIXED">{t('cryptoTailStrategy.form.spreadModeFixed')}</Radio>
|
||||||
<Radio value="NONE">{t('cryptoTailStrategy.form.minSpreadModeNone')}</Radio>
|
<Radio value="NONE">{t('cryptoTailStrategy.form.spreadModeNone')}</Radio>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
noStyle
|
noStyle
|
||||||
shouldUpdate={(prev, curr) => prev.minSpreadMode !== curr.minSpreadMode}
|
shouldUpdate={(prev, curr) => prev.spreadMode !== curr.spreadMode}
|
||||||
>
|
>
|
||||||
{({ getFieldValue }) =>
|
{({ getFieldValue }) =>
|
||||||
getFieldValue('minSpreadMode') === 'FIXED' ? (
|
getFieldValue('spreadMode') === 'FIXED' ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="minSpreadValue"
|
name="spreadValue"
|
||||||
label={t('cryptoTailStrategy.form.minSpreadValue')}
|
label={t('cryptoTailStrategy.form.spreadValue')}
|
||||||
rules={[{ required: true }]}
|
rules={[{ required: true }]}
|
||||||
>
|
>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
placeholder={t('cryptoTailStrategy.form.minSpreadValuePlaceholder')}
|
placeholder={t('cryptoTailStrategy.form.spreadValuePlaceholder')}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
stringMode
|
stringMode
|
||||||
/>
|
/>
|
||||||
@@ -855,6 +1010,22 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="spreadDirection"
|
||||||
|
label={
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{t('cryptoTailStrategy.form.spreadDirection')}</span>
|
||||||
|
<Tooltip title={t('cryptoTailStrategy.form.spreadDirectionTip')}>
|
||||||
|
<InfoCircleOutlined style={{ color: '#999', cursor: 'help', fontSize: 14 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio value="MIN">{t('cryptoTailStrategy.form.spreadDirectionMin')}</Radio>
|
||||||
|
<Radio value="MAX">{t('cryptoTailStrategy.form.spreadDirectionMax')}</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="enabled" valuePropName="checked">
|
<Form.Item name="enabled" valuePropName="checked">
|
||||||
<Switch checkedChildren={t('common.enabled')} unCheckedChildren={t('common.disabled')} />
|
<Switch checkedChildren={t('common.enabled')} unCheckedChildren={t('common.disabled')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -923,9 +1094,9 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
render: (i: number) =>
|
render: (i: number) =>
|
||||||
i === 0 ? (
|
i === 0 ? (
|
||||||
<Tag icon={<ArrowUpOutlined />} color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
|
<Tag color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag icon={<ArrowDownOutlined />} color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
|
<Tag color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -998,9 +1169,9 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
render: (i: number) =>
|
render: (i: number) =>
|
||||||
i === 0 ? (
|
i === 0 ? (
|
||||||
<Tag icon={<ArrowUpOutlined />} color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
|
<Tag color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag icon={<ArrowDownOutlined />} color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
|
<Tag color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+285
-112
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col } from 'antd'
|
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col, Tooltip, Badge } from 'antd'
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons'
|
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined, CopyOutlined, LineChartOutlined } from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import type { Leader, LeaderBalanceResponse } from '../types'
|
import type { Leader, LeaderBalanceResponse } from '../types'
|
||||||
@@ -225,11 +225,11 @@ const LeaderList: React.FC = () => {
|
|||||||
title: t('leaderList.leaderName'),
|
title: t('leaderList.leaderName'),
|
||||||
dataIndex: 'leaderName',
|
dataIndex: 'leaderName',
|
||||||
key: 'leaderName',
|
key: 'leaderName',
|
||||||
width: 150,
|
width: 200,
|
||||||
render: (text: string, record: Leader) => (
|
render: (text: string, record: Leader) => (
|
||||||
<Space direction="vertical" size={0}>
|
<Space direction="vertical" size={0}>
|
||||||
<Text strong style={{ fontSize: '14px' }}>{text || `Leader ${record.id}`}</Text>
|
<Text strong style={{ fontSize: '14px' }}>{text || `Leader ${record.id}`}</Text>
|
||||||
<Text type="secondary" style={{ fontSize: '12px' }}>{record.leaderAddress}</Text>
|
<Text type="secondary" style={{ fontSize: '12px', fontFamily: 'monospace' }}>{record.leaderAddress}</Text>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -237,76 +237,155 @@ const LeaderList: React.FC = () => {
|
|||||||
title: t('leaderList.remark'),
|
title: t('leaderList.remark'),
|
||||||
dataIndex: 'remark',
|
dataIndex: 'remark',
|
||||||
key: 'remark',
|
key: 'remark',
|
||||||
width: 200,
|
width: 180,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (remark: string | undefined) => {
|
render: (remark: string | undefined) => {
|
||||||
if (!remark) return <Text type="secondary">-</Text>
|
if (!remark) return <Text type="secondary">-</Text>
|
||||||
return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 180 }}>{remark}</Text>
|
return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 160 }}>{remark}</Text>
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('leaderDetail.availableBalance'),
|
title: t('leaderDetail.availableBalance'),
|
||||||
key: 'balance',
|
key: 'balance',
|
||||||
width: 150,
|
width: 180,
|
||||||
render: (_: any, record: Leader) => {
|
render: (_: any, record: Leader) => {
|
||||||
const balance = balanceMap[record.id]
|
const balance = balanceMap[record.id]
|
||||||
if (!balance) return <Spin size="small" />
|
if (!balance) return <Spin size="small" />
|
||||||
const displayText = balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`
|
return (
|
||||||
return <Text style={{ color: '#1890ff', fontSize: '14px' }}>{displayText}</Text>
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text style={{ color: '#52c41a', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
{balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
{t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t('leaderList.copyTradingCount'),
|
|
||||||
dataIndex: 'copyTradingCount',
|
|
||||||
key: 'copyTradingCount',
|
|
||||||
width: 100,
|
|
||||||
render: (count: number, record: Leader) => (
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
onClick={() => navigate(`/copy-trading?leaderId=${record.id}`)}
|
|
||||||
disabled={count === 0}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
<Tag color="cyan">{count}</Tag>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('leaderList.backtestCount'),
|
|
||||||
dataIndex: 'backtestCount',
|
|
||||||
key: 'backtestCount',
|
|
||||||
width: 100,
|
|
||||||
render: (count: number, record: Leader) => (
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
onClick={() => navigate(`/backtest?leaderId=${record.id}`)}
|
|
||||||
disabled={count === 0}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
<Tag color="purple">{count}</Tag>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: t('common.actions'),
|
title: t('common.actions'),
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: isMobile ? 180 : 250,
|
width: 200,
|
||||||
fixed: 'right' as const,
|
fixed: 'right' as const,
|
||||||
render: (_: any, record: Leader) => (
|
render: (_: any, record: Leader) => (
|
||||||
<Space size="small" wrap>
|
<Space size={4}>
|
||||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(record)}>
|
<Tooltip title={t('common.viewDetail')}>
|
||||||
{t('common.viewDetail')}
|
<div
|
||||||
</Button>
|
onClick={() => handleShowDetail(record)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<EyeOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{record.website && (
|
{record.website && (
|
||||||
<Button type="link" size="small" icon={<GlobalOutlined />} onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}>
|
<Tooltip title={t('leaderList.openWebsite')}>
|
||||||
{t('leaderList.openWebsite')}
|
<div
|
||||||
</Button>
|
onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<GlobalOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${record.id}`)}>
|
|
||||||
{t('common.edit')}
|
<Tooltip title={t('common.edit')}>
|
||||||
</Button>
|
<div
|
||||||
|
onClick={() => navigate(`/leaders/edit?id=${record.id}`)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={`${t('leaderList.viewCopyTradings')} (${record.copyTradingCount})`}>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
if (record.copyTradingCount > 0) {
|
||||||
|
navigate(`/copy-trading?leaderId=${record.id}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: record.copyTradingCount === 0 ? 'not-allowed' : 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
opacity: record.copyTradingCount === 0 ? 0.4 : 1,
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => record.copyTradingCount > 0 && (e.currentTarget.style.backgroundColor = '#f0f0f0')}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<Badge count={record.copyTradingCount} size="small" offset={[-4, -4]}>
|
||||||
|
<CopyOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={`${t('leaderList.viewBacktests')} (${record.backtestCount})`}>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
if (record.backtestCount > 0) {
|
||||||
|
navigate(`/backtest?leaderId=${record.id}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: record.backtestCount === 0 ? 'not-allowed' : 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
opacity: record.backtestCount === 0 ? 0.4 : 1,
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => record.backtestCount > 0 && (e.currentTarget.style.backgroundColor = '#f0f0f0')}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<Badge count={record.backtestCount} size="small" offset={[-4, -4]}>
|
||||||
|
<LineChartOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t('leaderList.deleteConfirm')}
|
title={t('leaderList.deleteConfirm')}
|
||||||
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) : undefined}
|
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) : undefined}
|
||||||
@@ -314,9 +393,24 @@ const LeaderList: React.FC = () => {
|
|||||||
okText={t('common.confirm')}
|
okText={t('common.confirm')}
|
||||||
cancelText={t('common.cancel')}
|
cancelText={t('common.cancel')}
|
||||||
>
|
>
|
||||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
<Tooltip title={t('common.delete')}>
|
||||||
{t('common.delete')}
|
<div
|
||||||
</Button>
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
@@ -327,9 +421,9 @@ const LeaderList: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
|
||||||
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('leaderList.title')}</h2>
|
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('leaderList.title')}</h2>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/leaders/add')} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}>
|
<Tooltip title={t('leaderList.addLeader')}>
|
||||||
{t('leaderList.addLeader')}
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/leaders/add')} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }} />
|
||||||
</Button>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
|
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
|
||||||
@@ -348,69 +442,145 @@ const LeaderList: React.FC = () => {
|
|||||||
const balance = balanceMap[leader.id]
|
const balance = balanceMap[leader.id]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={leader.id} style={{ marginBottom: '16px', borderRadius: '12px', boxShadow: '0 2px 6px rgba(0,0,0,0.06)', border: '1px solid #f0f0f0' }} bodyStyle={{ padding: '16px' }}>
|
<Card
|
||||||
<div style={{ marginBottom: '12px' }}>
|
key={leader.id}
|
||||||
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '6px', color: '#1890ff' }}>
|
style={{
|
||||||
{leader.leaderName || `Leader ${leader.id}`}
|
marginBottom: '10px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: '0' }}
|
||||||
|
>
|
||||||
|
{/* 头部区域 - 名称和地址 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: 'var(--ant-color-primary, #1677ff)',
|
||||||
|
color: '#fff'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span>{leader.leaderName || `Leader ${leader.id}`}</span>
|
||||||
|
{leader.website && (
|
||||||
|
<GlobalOutlined
|
||||||
|
style={{ fontSize: '13px', cursor: 'pointer', opacity: 0.8 }}
|
||||||
|
onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: '#666', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
<div style={{ fontSize: '10px', opacity: '0.85', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||||
{leader.leaderAddress}
|
{leader.leaderAddress}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{balance && (
|
{/* 资产区域 - 常驻显示 */}
|
||||||
<div style={{ marginBottom: '12px', padding: '12px', backgroundColor: '#f6ffed', borderRadius: '8px', border: '1px solid #b7eb8f' }}>
|
<div style={{
|
||||||
<div style={{ fontSize: '13px', color: '#52c41a', fontWeight: 'bold', marginBottom: '4px' }}>
|
padding: '8px 12px',
|
||||||
{t('leaderDetail.availableBalance')}: {balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`}
|
backgroundColor: '#fafafa',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
minHeight: '42px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
|
||||||
|
{t('leaderDetail.availableBalance')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}>
|
||||||
|
{balance?.available && balance.available !== '-' ? `${formatUSDC(balance.available)} USDC` : '- USDC'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '11px', color: '#666' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
{t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
|
||||||
|
{t('leaderDetail.positionBalance')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '500', color: '#722ed1' }}>
|
||||||
|
{balance?.position && balance.position !== '-' ? formatUSDC(balance.position) : '-'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
onClick={() => navigate(`/copy-trading?leaderId=${leader.id}`)}
|
|
||||||
disabled={leader.copyTradingCount === 0}
|
|
||||||
style={{ borderRadius: '6px', padding: '8px 16px' }}
|
|
||||||
>
|
|
||||||
{t('leaderList.viewCopyTradings')} ({leader.copyTradingCount})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
onClick={() => navigate(`/backtest?leaderId=${leader.id}`)}
|
|
||||||
disabled={leader.backtestCount === 0}
|
|
||||||
style={{ borderRadius: '6px', padding: '8px 16px' }}
|
|
||||||
>
|
|
||||||
{t('leaderList.viewBacktests')} ({leader.backtestCount})
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 备注区域 */}
|
||||||
{leader.remark && (
|
{leader.remark && (
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{
|
||||||
<Text type="secondary" style={{ fontSize: '12px' }}>{t('leaderList.remark')}:</Text>
|
padding: '6px 12px',
|
||||||
<Text style={{ fontSize: '12px', marginLeft: '4px' }}>{leader.remark}</Text>
|
backgroundColor: '#fffbe6',
|
||||||
|
borderBottom: '1px solid #ffe58f',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#8c8c8c'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: '#d48806' }}>{t('leaderList.remark')}:</span>
|
||||||
|
<span>{leader.remark}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
{/* 图标操作栏 */}
|
||||||
<Button type="primary" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(leader)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
|
<div style={{
|
||||||
{t('common.viewDetail')}
|
padding: '8px 12px',
|
||||||
</Button>
|
display: 'flex',
|
||||||
{leader.website && (
|
justifyContent: 'space-around',
|
||||||
<Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
|
alignItems: 'center'
|
||||||
{t('leaderList.openWebsite')}
|
}}>
|
||||||
</Button>
|
<Tooltip title={t('common.viewDetail')}>
|
||||||
)}
|
<div
|
||||||
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
|
onClick={() => handleShowDetail(leader)}
|
||||||
{t('common.edit')}
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
|
||||||
</Button>
|
>
|
||||||
|
<EyeOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.viewDetail')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('common.edit')}>
|
||||||
|
<div
|
||||||
|
onClick={() => navigate(`/leaders/edit?id=${leader.id}`)}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
<EditOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.edit')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('leaderList.viewCopyTradings')}>
|
||||||
|
<div
|
||||||
|
onClick={() => navigate(`/copy-trading?leaderId=${leader.id}`)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: leader.copyTradingCount === 0 ? 'not-allowed' : 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
opacity: leader.copyTradingCount === 0 ? 0.4 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge count={leader.copyTradingCount} size="small" offset={[-2, -2]}>
|
||||||
|
<CopyOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
</Badge>
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('leaderList.viewCopyTradings')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('leaderList.viewBacktests')}>
|
||||||
|
<div
|
||||||
|
onClick={() => navigate(`/backtest?leaderId=${leader.id}`)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: leader.backtestCount === 0 ? 'not-allowed' : 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
opacity: leader.backtestCount === 0 ? 0.4 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge count={leader.backtestCount} size="small" offset={[-2, -2]}>
|
||||||
|
<LineChartOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
</Badge>
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('leaderList.viewBacktests')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t('leaderList.deleteConfirm')}
|
title={t('leaderList.deleteConfirm')}
|
||||||
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) : undefined}
|
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) : undefined}
|
||||||
@@ -418,9 +588,12 @@ const LeaderList: React.FC = () => {
|
|||||||
okText={t('common.confirm')}
|
okText={t('common.confirm')}
|
||||||
cancelText={t('common.cancel')}
|
cancelText={t('common.cancel')}
|
||||||
>
|
>
|
||||||
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
|
<Tooltip title={t('common.delete')}>
|
||||||
{t('common.delete')}
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
</Button>
|
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,718 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input, Switch, Tooltip, Row, Col, Tabs } from 'antd'
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SendOutlined, ReloadOutlined, CheckOutlined, RobotOutlined, FormOutlined } from '@ant-design/icons'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { apiService } from '../services/api'
|
||||||
|
import type { NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest, NotificationTemplate, TemplateTypeInfo, TemplateVariablesResponse, TemplateVariable } from '../types'
|
||||||
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
import { TelegramConfigForm } from '../components/notifications'
|
||||||
|
import TextArea from 'antd/es/input/TextArea'
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography
|
||||||
|
|
||||||
|
const variableTagStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: 6,
|
||||||
|
marginRight: 6,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
background: '#ffffff',
|
||||||
|
color: 'rgba(0, 0, 0, 0.65)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const variableTagHoverStyle: React.CSSProperties = {
|
||||||
|
borderColor: '#1890ff',
|
||||||
|
background: '#e6f7ff',
|
||||||
|
color: '#1890ff',
|
||||||
|
transform: 'translateY(-1px)',
|
||||||
|
boxShadow: '0 2px 4px rgba(24, 144, 255, 0.2)',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 变量分类标签映射
|
||||||
|
*/
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
common: 'notificationSettings.templates.commonVariables',
|
||||||
|
order: 'notificationSettings.templates.orderVariables',
|
||||||
|
copy_trading: 'notificationSettings.templates.copyTradingVariables',
|
||||||
|
redeem: 'notificationSettings.templates.redeemVariables',
|
||||||
|
error: 'notificationSettings.templates.errorVariables',
|
||||||
|
filter: 'notificationSettings.templates.filterVariables',
|
||||||
|
strategy: 'notificationSettings.templates.strategyVariables'
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationSettingsPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
|
||||||
|
// 机器人配置相关状态
|
||||||
|
const [configs, setConfigs] = useState<NotificationConfig[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
|
const [editingConfig, setEditingConfig] = useState<NotificationConfig | null>(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [testLoading, setTestLoading] = useState(false)
|
||||||
|
|
||||||
|
// 模板配置相关状态
|
||||||
|
const [templateTypes, setTemplateTypes] = useState<TemplateTypeInfo[]>([])
|
||||||
|
const [selectedTemplateType, setSelectedTemplateType] = useState<string>('ORDER_SUCCESS')
|
||||||
|
const [currentTemplate, setCurrentTemplate] = useState<NotificationTemplate | null>(null)
|
||||||
|
const [templateVariables, setTemplateVariables] = useState<TemplateVariablesResponse | null>(null)
|
||||||
|
const [templateContent, setTemplateContent] = useState('')
|
||||||
|
const [testTemplateLoading, setTestTemplateLoading] = useState(false)
|
||||||
|
|
||||||
|
// 加载机器人配置
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfigs()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 加载模板类型
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTemplateTypes()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 当选中的模板类型改变时,加载模板详情和变量
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTemplateType) {
|
||||||
|
fetchTemplateDetail(selectedTemplateType)
|
||||||
|
fetchTemplateVariables(selectedTemplateType)
|
||||||
|
}
|
||||||
|
}, [selectedTemplateType])
|
||||||
|
|
||||||
|
const fetchConfigs = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await apiService.notifications.list({ type: 'telegram' })
|
||||||
|
if (response.data.code === 0 && response.data.data) {
|
||||||
|
setConfigs(response.data.data)
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('notificationSettings.fetchFailed'))
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || t('notificationSettings.fetchFailed'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTemplateTypes = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiService.notifications.getTemplateTypes()
|
||||||
|
if (response.data.code === 0 && response.data.data) {
|
||||||
|
setTemplateTypes(response.data.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取模板类型失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTemplateDetail = async (templateType: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiService.notifications.getTemplateDetail({ templateType })
|
||||||
|
if (response.data.code === 0 && response.data.data) {
|
||||||
|
setCurrentTemplate(response.data.data)
|
||||||
|
setTemplateContent(response.data.data.templateContent)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取模板详情失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTemplateVariables = async (templateType: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiService.notifications.getTemplateVariables({ templateType })
|
||||||
|
if (response.data.code === 0 && response.data.data) {
|
||||||
|
setTemplateVariables(response.data.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取模板变量失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 机器人配置相关方法
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingConfig(null)
|
||||||
|
form.resetFields()
|
||||||
|
form.setFieldsValue({
|
||||||
|
type: 'telegram',
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
botToken: '',
|
||||||
|
chatIds: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (config: NotificationConfig) => {
|
||||||
|
setEditingConfig(config)
|
||||||
|
let botToken = ''
|
||||||
|
let chatIds = ''
|
||||||
|
|
||||||
|
if (config.config) {
|
||||||
|
if ('data' in config.config && config.config.data) {
|
||||||
|
const data = config.config.data as any
|
||||||
|
botToken = data.botToken || ''
|
||||||
|
if (data.chatIds) {
|
||||||
|
if (Array.isArray(data.chatIds)) {
|
||||||
|
chatIds = data.chatIds.join(',')
|
||||||
|
} else if (typeof data.chatIds === 'string') {
|
||||||
|
chatIds = data.chatIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ('botToken' in config.config) {
|
||||||
|
botToken = (config.config as any).botToken || ''
|
||||||
|
}
|
||||||
|
if ('chatIds' in config.config) {
|
||||||
|
const ids = (config.config as any).chatIds
|
||||||
|
if (Array.isArray(ids)) {
|
||||||
|
chatIds = ids.join(',')
|
||||||
|
} else if (typeof ids === 'string') {
|
||||||
|
chatIds = ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setFieldsValue({
|
||||||
|
type: config.type,
|
||||||
|
name: config.name,
|
||||||
|
enabled: config.enabled,
|
||||||
|
config: {
|
||||||
|
botToken: botToken,
|
||||||
|
chatIds: chatIds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const response = await apiService.notifications.delete({ id })
|
||||||
|
if (response.data.code === 0) {
|
||||||
|
message.success(t('notificationSettings.deleteSuccess'))
|
||||||
|
fetchConfigs()
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('notificationSettings.deleteFailed'))
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || t('notificationSettings.deleteFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateEnabled = async (id: number, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const response = await apiService.notifications.updateEnabled({ id, enabled })
|
||||||
|
if (response.data.code === 0) {
|
||||||
|
message.success(enabled ? t('notificationSettings.enableSuccess') : t('notificationSettings.disableSuccess'))
|
||||||
|
fetchConfigs()
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('notificationSettings.updateStatusFailed'))
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || t('notificationSettings.updateStatusFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTestLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await apiService.notifications.test({ message: '这是一条测试消息' })
|
||||||
|
if (response.data.code === 0 && response.data.data) {
|
||||||
|
message.success(t('notificationSettings.testSuccess'))
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('notificationSettings.testFailed'))
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || t('notificationSettings.testFailed'))
|
||||||
|
} finally {
|
||||||
|
setTestLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
const chatIds = typeof values.config.chatIds === 'string'
|
||||||
|
? values.config.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id)
|
||||||
|
: values.config.chatIds || []
|
||||||
|
|
||||||
|
const configData: NotificationConfigRequest | NotificationConfigUpdateRequest = {
|
||||||
|
type: values.type,
|
||||||
|
name: values.name,
|
||||||
|
enabled: values.enabled,
|
||||||
|
config: {
|
||||||
|
botToken: values.config.botToken,
|
||||||
|
chatIds: chatIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingConfig?.id) {
|
||||||
|
const updateData = {
|
||||||
|
...configData,
|
||||||
|
id: editingConfig.id
|
||||||
|
} as NotificationConfigUpdateRequest
|
||||||
|
const response = await apiService.notifications.update(updateData)
|
||||||
|
if (response.data.code === 0) {
|
||||||
|
message.success(t('notificationSettings.updateSuccess'))
|
||||||
|
setModalVisible(false)
|
||||||
|
fetchConfigs()
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('notificationSettings.updateFailed'))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await apiService.notifications.create(configData)
|
||||||
|
if (response.data.code === 0) {
|
||||||
|
message.success(t('notificationSettings.createSuccess'))
|
||||||
|
setModalVisible(false)
|
||||||
|
fetchConfigs()
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('notificationSettings.createFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.errorFields) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.error(error.message || t('message.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConfigFormComponent = (type: string) => {
|
||||||
|
switch (type?.toLowerCase()) {
|
||||||
|
case 'telegram':
|
||||||
|
return <TelegramConfigForm form={form} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板配置相关方法
|
||||||
|
const handleTemplateTypeChange = (type: string) => {
|
||||||
|
setSelectedTemplateType(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTemplateContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setTemplateContent(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiService.notifications.updateTemplate({
|
||||||
|
templateType: selectedTemplateType,
|
||||||
|
templateContent: templateContent
|
||||||
|
})
|
||||||
|
if (response.data.code === 0) {
|
||||||
|
message.success(t('notificationSettings.templates.saveSuccess'))
|
||||||
|
fetchTemplateDetail(selectedTemplateType)
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('notificationSettings.templates.saveFailed'))
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || t('notificationSettings.templates.saveFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiService.notifications.resetTemplate({
|
||||||
|
templateType: selectedTemplateType
|
||||||
|
})
|
||||||
|
if (response.data.code === 0) {
|
||||||
|
message.success(t('notificationSettings.templates.resetSuccess'))
|
||||||
|
fetchTemplateDetail(selectedTemplateType)
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('notificationSettings.templates.resetFailed'))
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || t('notificationSettings.templates.resetFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTestTemplate = async () => {
|
||||||
|
setTestTemplateLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await apiService.notifications.testTemplate({
|
||||||
|
templateType: selectedTemplateType,
|
||||||
|
templateContent: templateContent
|
||||||
|
})
|
||||||
|
if (response.data.code === 0 && response.data.data) {
|
||||||
|
message.success(t('notificationSettings.templates.testSuccess'))
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('notificationSettings.templates.testFailed'))
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || t('notificationSettings.templates.testFailed'))
|
||||||
|
} finally {
|
||||||
|
setTestTemplateLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyVariable = useCallback((variable: string) => {
|
||||||
|
const text = `{{${variable}}}`
|
||||||
|
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
message.success(t('notificationSettings.templates.copied'))
|
||||||
|
}).catch(() => {
|
||||||
|
fallbackCopy(text)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fallbackCopy(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy(text: string) {
|
||||||
|
const textArea = document.createElement('textarea')
|
||||||
|
textArea.value = text
|
||||||
|
textArea.style.position = 'fixed'
|
||||||
|
textArea.style.left = '-9999px'
|
||||||
|
textArea.style.top = '-9999px'
|
||||||
|
document.body.appendChild(textArea)
|
||||||
|
textArea.focus()
|
||||||
|
textArea.select()
|
||||||
|
try {
|
||||||
|
document.execCommand('copy')
|
||||||
|
message.success(t('notificationSettings.templates.copied'))
|
||||||
|
} catch {
|
||||||
|
message.error(t('common.copyFailed'))
|
||||||
|
}
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const [variableHoverKey, setVariableHoverKey] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const renderVariableItem = (variable: TemplateVariable) => {
|
||||||
|
const isHover = variableHoverKey === variable.key
|
||||||
|
const label = t(`notificationSettings.templates.variableLabels.${variable.key}`)
|
||||||
|
const description = t(`notificationSettings.templates.variableDescriptions.${variable.key}`)
|
||||||
|
const variableElement = (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ ...variableTagStyle, ...(isHover && !isMobile ? variableTagHoverStyle : {}) }}
|
||||||
|
onClick={() => handleCopyVariable(variable.key)}
|
||||||
|
onMouseEnter={() => !isMobile && setVariableHoverKey(variable.key)}
|
||||||
|
onMouseLeave={() => !isMobile && setVariableHoverKey(null)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleCopyVariable(variable.key)}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: 'monospace' }}>{label}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<span key={variable.key} style={{ display: 'inline-block' }}>
|
||||||
|
{variableElement}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={variable.key} title={description || `{{${variable.key}}}`} placement="top">
|
||||||
|
{variableElement}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderVariablesPanel = () => {
|
||||||
|
if (!templateVariables) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||||
|
{t('notificationSettings.templates.variables')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
style={{ height: '100%', borderRadius: 8 }}
|
||||||
|
bodyStyle={{ padding: '12px 16px', maxHeight: 420, overflowY: 'auto' }}
|
||||||
|
>
|
||||||
|
{templateVariables.categories.map(category => {
|
||||||
|
const categoryVariables = templateVariables.variables.filter(v => v.category === category.key)
|
||||||
|
if (categoryVariables.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div key={category.key} style={{ marginBottom: 16 }}>
|
||||||
|
<Text type="secondary" style={{ marginBottom: 8, display: 'block', fontSize: 12 }}>
|
||||||
|
{t(CATEGORY_LABELS[category.key])}
|
||||||
|
</Text>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||||
|
{categoryVariables.sort((a, b) => a.sortOrder - b.sortOrder).map(renderVariableItem)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0, fontSize: 11, textAlign: 'center' }}>
|
||||||
|
{t('notificationSettings.templates.clickToCopy')}
|
||||||
|
</Paragraph>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 机器人配置表格列
|
||||||
|
const configColumns = [
|
||||||
|
{
|
||||||
|
title: t('notificationSettings.configName'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('notificationSettings.type'),
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
render: (type: string) => <Tag color="blue">{type.toUpperCase()}</Tag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('notificationSettings.status'),
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
key: 'enabled',
|
||||||
|
render: (enabled: boolean) => (
|
||||||
|
<Tag color={enabled ? 'green' : 'default'}>
|
||||||
|
{enabled ? t('notificationSettings.enabledStatus') : t('notificationSettings.disabledStatus')}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('notificationSettings.chatIds'),
|
||||||
|
key: 'chatIds',
|
||||||
|
render: (_: any, record: NotificationConfig) => {
|
||||||
|
let chatIds: string[] = []
|
||||||
|
if (record.config) {
|
||||||
|
if ('data' in record.config && record.config.data) {
|
||||||
|
const data = (record.config as any).data
|
||||||
|
if (data.chatIds) {
|
||||||
|
if (Array.isArray(data.chatIds)) {
|
||||||
|
chatIds = data.chatIds.filter((id: any) => id && String(id).trim())
|
||||||
|
} else if (typeof data.chatIds === 'string') {
|
||||||
|
chatIds = data.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ('chatIds' in record.config) {
|
||||||
|
const ids = (record.config as any).chatIds
|
||||||
|
if (Array.isArray(ids)) {
|
||||||
|
chatIds = ids.filter((id: any) => id && String(id).trim())
|
||||||
|
} else if (typeof ids === 'string') {
|
||||||
|
chatIds = (ids as string).split(',').map((id: string) => id.trim()).filter((id: string) => id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chatIds.length > 0 ? (
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
{chatIds.join(', ')}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="danger" style={{ fontSize: '12px' }}>{t('notificationSettings.chatIdsNotConfigured')}</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('common.actions'),
|
||||||
|
key: 'action',
|
||||||
|
width: isMobile ? 120 : 200,
|
||||||
|
render: (_: any, record: NotificationConfig) => (
|
||||||
|
<Space size="small" wrap>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
>
|
||||||
|
{t('notificationSettings.edit')}
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
checked={record.enabled}
|
||||||
|
size="small"
|
||||||
|
onChange={(checked) => handleUpdateEnabled(record.id!, checked)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
loading={testLoading}
|
||||||
|
onClick={handleTest}
|
||||||
|
>
|
||||||
|
{t('notificationSettings.test')}
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={t('notificationSettings.deleteConfirm')}
|
||||||
|
onConfirm={() => handleDelete(record.id!)}
|
||||||
|
okText={t('common.confirm')}
|
||||||
|
cancelText={t('common.cancel')}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
>
|
||||||
|
{t('notificationSettings.delete')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const templateTypeTabItems = templateTypes.map(type => ({
|
||||||
|
key: type.type,
|
||||||
|
label: (
|
||||||
|
<Tooltip title={t(`notificationSettings.templateTypeDescriptions.${type.type}`)} placement="top">
|
||||||
|
<span>{t(`notificationSettings.templateTypes.${type.type}`)}</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<Title level={2} style={{ margin: 0 }}>{t('notificationSettings.title')}</Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 机器人配置 */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<RobotOutlined />
|
||||||
|
<span>{t('notificationSettings.botConfig')}</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||||
|
{t('notificationSettings.addConfig')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
columns={configColumns}
|
||||||
|
dataSource={configs}
|
||||||
|
loading={loading}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: isMobile ? 600 : 'auto' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 模板配置 */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FormOutlined />
|
||||||
|
<span>{t('notificationSettings.templateConfig')}</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
activeKey={selectedTemplateType}
|
||||||
|
onChange={handleTemplateTypeChange}
|
||||||
|
items={templateTypeTabItems}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
tabBarStyle={{ marginBottom: 0 }}
|
||||||
|
type={isMobile ? 'line' : 'card'}
|
||||||
|
size={isMobile ? 'small' : 'middle'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} sm={24} md={17}>
|
||||||
|
<Card size="small" bordered={false} style={{ background: '#fafafa', marginBottom: 12, borderRadius: 8 }} bodyStyle={{ padding: '10px 16px' }}>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Space wrap size="small">
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>{t('notificationSettings.templates.templateContent')}</Text>
|
||||||
|
{currentTemplate && (
|
||||||
|
<Tag color={currentTemplate.isDefault ? 'green' : 'blue'} style={{ margin: 0 }}>
|
||||||
|
{currentTemplate.isDefault ? t('notificationSettings.templates.isDefault') : t('notificationSettings.templates.isCustom')}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
<Space wrap size="small">
|
||||||
|
<Popconfirm
|
||||||
|
title={t('notificationSettings.templates.resetConfirm')}
|
||||||
|
onConfirm={handleResetTemplate}
|
||||||
|
okText={t('common.confirm')}
|
||||||
|
cancelText={t('common.cancel')}
|
||||||
|
>
|
||||||
|
<Button size="small" icon={<ReloadOutlined />}>
|
||||||
|
{t('notificationSettings.templates.resetToDefault')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Button size="small" type="primary" icon={<CheckOutlined />} onClick={handleSaveTemplate}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" icon={<SendOutlined />} loading={testTemplateLoading} onClick={handleTestTemplate}>
|
||||||
|
{t('notificationSettings.test')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<TextArea
|
||||||
|
value={templateContent}
|
||||||
|
onChange={handleTemplateContentChange}
|
||||||
|
rows={isMobile ? 15 : 16}
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: 13, borderRadius: 8, resize: 'none' }}
|
||||||
|
placeholder={t('notificationSettings.templates.contentPlaceholder')}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={24} md={7}>
|
||||||
|
{renderVariablesPanel()}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingConfig ? t('notificationSettings.editConfig') : t('notificationSettings.addConfig')}
|
||||||
|
open={modalVisible}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
onCancel={() => setModalVisible(false)}
|
||||||
|
width={isMobile ? '90%' : 600}
|
||||||
|
okText={t('common.confirm')}
|
||||||
|
cancelText={t('common.cancel')}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="type"
|
||||||
|
label={t('notificationSettings.type')}
|
||||||
|
rules={[{ required: true, message: t('notificationSettings.typeRequired') }]}
|
||||||
|
>
|
||||||
|
<Input disabled value="telegram" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label={t('notificationSettings.configName')}
|
||||||
|
rules={[{ required: true, message: t('notificationSettings.configNameRequired') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('notificationSettings.configNamePlaceholder')} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="enabled"
|
||||||
|
label={t('notificationSettings.enabled')}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item shouldUpdate={(prevValues, currentValues) => {
|
||||||
|
return prevValues.type !== currentValues.type ||
|
||||||
|
prevValues.config !== currentValues.config
|
||||||
|
}}>
|
||||||
|
{() => {
|
||||||
|
const currentType = form.getFieldValue('type') || 'telegram'
|
||||||
|
return getConfigFormComponent(currentType)
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationSettingsPage
|
||||||
@@ -833,7 +833,7 @@ const PositionList: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据仓位类型动态生成列(历史仓位不显示当前价格、当前价值、状态列)
|
// 根据仓位类型动态生成列(优化后的紧凑布局)
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const baseColumns: any[] = [
|
const baseColumns: any[] = [
|
||||||
{
|
{
|
||||||
@@ -853,7 +853,6 @@ const PositionList: React.FC = () => {
|
|||||||
objectFit: 'cover'
|
objectFit: 'cover'
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// 图片加载失败时隐藏
|
|
||||||
e.currentTarget.style.display = 'none'
|
e.currentTarget.style.display = 'none'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -876,7 +875,7 @@ const PositionList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
fixed: isMobile ? ('left' as const) : undefined,
|
fixed: isMobile ? ('left' as const) : undefined,
|
||||||
width: isMobile ? 150 : 200
|
width: isMobile ? 120 : 160
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '市场',
|
title: '市场',
|
||||||
@@ -924,7 +923,7 @@ const PositionList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
width: isMobile ? 200 : 250
|
width: isMobile ? 180 : 220
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '方向',
|
title: '方向',
|
||||||
@@ -933,136 +932,76 @@ const PositionList: React.FC = () => {
|
|||||||
render: (side: string) => (
|
render: (side: string) => (
|
||||||
<Tag color={getSideColor(side)}>{side}</Tag>
|
<Tag color={getSideColor(side)}>{side}</Tag>
|
||||||
),
|
),
|
||||||
width: 80
|
width: 70
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '数量',
|
title: '持仓',
|
||||||
dataIndex: 'quantity',
|
key: 'position',
|
||||||
key: 'quantity',
|
render: (_: any, record: AccountPosition) => (
|
||||||
render: (quantity: string) => formatNumber(quantity, 4),
|
<div>
|
||||||
|
<div style={{ fontWeight: '500' }}>{formatNumber(record.quantity, 4)}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#999' }}>@{formatNumber(record.avgPrice, 4)}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
align: 'right' as const,
|
align: 'right' as const,
|
||||||
width: 100
|
width: 100
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '平均价格',
|
|
||||||
dataIndex: 'avgPrice',
|
|
||||||
key: 'avgPrice',
|
|
||||||
render: (price: string) => formatNumber(price, 4),
|
|
||||||
align: 'right' as const,
|
|
||||||
width: 120
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '开仓价值',
|
title: '开仓价值',
|
||||||
dataIndex: 'initialValue',
|
dataIndex: 'initialValue',
|
||||||
key: 'initialValue',
|
key: 'initialValue',
|
||||||
render: (value: string) => (
|
render: (value: string) => (
|
||||||
<span>
|
<span>{formatUSDC(value)} USDC</span>
|
||||||
{formatUSDC(value)} USDC
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
align: 'right' as const,
|
align: 'right' as const,
|
||||||
width: 120
|
width: 110
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 只有当前仓位才显示当前价格和当前价值列
|
// 只有当前仓位才显示当前价值/盈亏合并列
|
||||||
if (positionFilter === 'current') {
|
if (positionFilter === 'current') {
|
||||||
baseColumns.push(
|
baseColumns.push({
|
||||||
{
|
title: '当前价值 / 盈亏',
|
||||||
title: '当前价格',
|
key: 'valueAndPnl',
|
||||||
dataIndex: 'currentPrice',
|
render: (_: any, record: AccountPosition) => {
|
||||||
key: 'currentPrice',
|
const pnlNum = parseFloat(record.pnl || '0')
|
||||||
render: (price: string) => formatNumber(price, 4),
|
const realizedPnl = record.realizedPnl ? parseFloat(record.realizedPnl) : null
|
||||||
align: 'right' as const,
|
const percentRealizedPnl = record.percentRealizedPnl ? parseFloat(record.percentRealizedPnl) : null
|
||||||
width: 120
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '当前价值',
|
|
||||||
dataIndex: 'currentValue',
|
|
||||||
key: 'currentValue',
|
|
||||||
render: (value: string) => (
|
|
||||||
<span style={{ fontWeight: 'bold' }}>
|
|
||||||
{formatUSDC(value)} USDC
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
align: 'right' as const,
|
|
||||||
width: 120,
|
|
||||||
sorter: (a: AccountPosition, b: AccountPosition) => {
|
|
||||||
const valA = parseFloat(a.currentValue || '0')
|
|
||||||
const valB = parseFloat(b.currentValue || '0')
|
|
||||||
return valA - valB
|
|
||||||
},
|
|
||||||
defaultSortOrder: 'descend' as const
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只有当前仓位才显示盈亏和已实现盈亏列
|
return (
|
||||||
if (positionFilter === 'current') {
|
<div>
|
||||||
baseColumns.push(
|
<div style={{ fontWeight: '600', marginBottom: '2px' }}>
|
||||||
{
|
{formatUSDC(record.currentValue)} USDC
|
||||||
title: '盈亏',
|
|
||||||
dataIndex: 'pnl',
|
|
||||||
key: 'pnl',
|
|
||||||
render: (pnl: string, record: AccountPosition) => {
|
|
||||||
const pnlNum = parseFloat(pnl || '0')
|
|
||||||
const percentPnl = parseFloat(record.percentPnl || '0')
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{
|
|
||||||
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>
|
|
||||||
{pnlNum >= 0 ? '+' : ''}{formatUSDC(pnl)} USDC
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
|
|
||||||
}}>
|
|
||||||
{formatPercent(record.percentPnl)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
<div style={{
|
||||||
},
|
fontSize: '13px',
|
||||||
align: 'right' as const,
|
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
||||||
width: 150,
|
fontWeight: '500'
|
||||||
sorter: (a: AccountPosition, b: AccountPosition) => {
|
}}>
|
||||||
const pnlA = parseFloat(a.pnl || '0')
|
{pnlNum >= 0 ? '+' : ''}{formatUSDC(record.pnl)} ({formatPercent(record.percentPnl)})
|
||||||
const pnlB = parseFloat(b.pnl || '0')
|
</div>
|
||||||
return pnlA - pnlB
|
{realizedPnl !== null && (
|
||||||
}
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#999',
|
||||||
|
marginTop: '2px'
|
||||||
|
}}>
|
||||||
|
已实现: {realizedPnl >= 0 ? '+' : ''}{formatUSDC(record.realizedPnl)}
|
||||||
|
{percentRealizedPnl !== null && ` (${formatPercent(record.percentRealizedPnl)})`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
align: 'right' as const,
|
||||||
title: '已实现盈亏',
|
width: 160,
|
||||||
dataIndex: 'realizedPnl',
|
sorter: (a: AccountPosition, b: AccountPosition) => {
|
||||||
key: 'realizedPnl',
|
const valA = parseFloat(a.currentValue || '0')
|
||||||
render: (realizedPnl: string | undefined, record: AccountPosition) => {
|
const valB = parseFloat(b.currentValue || '0')
|
||||||
if (!realizedPnl) return '-'
|
return valA - valB
|
||||||
const pnlNum = parseFloat(realizedPnl)
|
},
|
||||||
const percentPnl = parseFloat(record.percentRealizedPnl || '0')
|
defaultSortOrder: 'descend' as const
|
||||||
return (
|
})
|
||||||
<div>
|
|
||||||
<div style={{
|
|
||||||
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>
|
|
||||||
{pnlNum >= 0 ? '+' : ''}{formatUSDC(realizedPnl)} USDC
|
|
||||||
</div>
|
|
||||||
{record.percentRealizedPnl && (
|
|
||||||
<div style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
|
|
||||||
}}>
|
|
||||||
{formatPercent(record.percentRealizedPnl)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
align: 'right' as const,
|
|
||||||
width: 150
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有当前仓位才显示操作列
|
// 只有当前仓位才显示操作列
|
||||||
@@ -1084,7 +1023,7 @@ const PositionList: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
width: 150,
|
width: 80,
|
||||||
fixed: isMobile ? ('right' as const) : undefined
|
fixed: isMobile ? ('right' as const) : undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert, Select, Table, Tag, Popconfirm, Modal } from 'antd'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined, NotificationOutlined, KeyOutlined, LinkOutlined, PlusOutlined, EditOutlined, DeleteOutlined, SendOutlined } from '@ant-design/icons'
|
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert, Select } from 'antd'
|
||||||
|
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined, NotificationOutlined, KeyOutlined, LinkOutlined, RightOutlined } from '@ant-design/icons'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { SystemConfig, BuilderApiKeyUpdateRequest, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest } from '../types'
|
import type { SystemConfig, BuilderApiKeyUpdateRequest } from '../types'
|
||||||
import { TelegramConfigForm } from '../components/notifications'
|
|
||||||
import SystemUpdate from './SystemUpdate'
|
import SystemUpdate from './SystemUpdate'
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography
|
const { Title, Text, Paragraph } = Typography
|
||||||
@@ -33,20 +33,13 @@ interface ProxyCheckResponse {
|
|||||||
const SystemSettings: React.FC = () => {
|
const SystemSettings: React.FC = () => {
|
||||||
const { t, i18n: i18nInstance } = useTranslation()
|
const { t, i18n: i18nInstance } = useTranslation()
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// 第一部分:多语言
|
// 第一部分:多语言
|
||||||
const [languageForm] = Form.useForm()
|
const [languageForm] = Form.useForm()
|
||||||
const [currentLang, setCurrentLang] = useState<string>('auto')
|
const [currentLang, setCurrentLang] = useState<string>('auto')
|
||||||
|
|
||||||
// 第二部分:消息推送设置
|
// 第二部分:Relayer配置
|
||||||
const [notificationConfigs, setNotificationConfigs] = useState<NotificationConfig[]>([])
|
|
||||||
const [notificationLoading, setNotificationLoading] = useState(false)
|
|
||||||
const [notificationModalVisible, setNotificationModalVisible] = useState(false)
|
|
||||||
const [editingNotificationConfig, setEditingNotificationConfig] = useState<NotificationConfig | null>(null)
|
|
||||||
const [notificationForm] = Form.useForm()
|
|
||||||
const [testLoading, setTestLoading] = useState(false)
|
|
||||||
|
|
||||||
// 第三部分:Relayer配置
|
|
||||||
const [relayerForm] = Form.useForm()
|
const [relayerForm] = Form.useForm()
|
||||||
const [autoRedeemForm] = Form.useForm()
|
const [autoRedeemForm] = Form.useForm()
|
||||||
const [systemConfig, setSystemConfig] = useState<SystemConfig | null>(null)
|
const [systemConfig, setSystemConfig] = useState<SystemConfig | null>(null)
|
||||||
@@ -67,7 +60,6 @@ const SystemSettings: React.FC = () => {
|
|||||||
languageForm.setFieldsValue({ language: savedLanguage })
|
languageForm.setFieldsValue({ language: savedLanguage })
|
||||||
|
|
||||||
// 加载其他配置
|
// 加载其他配置
|
||||||
fetchNotificationConfigs()
|
|
||||||
fetchSystemConfig()
|
fetchSystemConfig()
|
||||||
fetchProxyConfig()
|
fetchProxyConfig()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -103,246 +95,7 @@ const SystemSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 第二部分:消息推送设置 ====================
|
// ==================== 第二部分:Relayer配置 ====================
|
||||||
const fetchNotificationConfigs = async () => {
|
|
||||||
setNotificationLoading(true)
|
|
||||||
try {
|
|
||||||
const response = await apiService.notifications.list({ type: 'telegram' })
|
|
||||||
if (response.data.code === 0 && response.data.data) {
|
|
||||||
setNotificationConfigs(response.data.data)
|
|
||||||
} else {
|
|
||||||
message.error(response.data.msg || t('notificationSettings.fetchFailed'))
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.message || t('notificationSettings.fetchFailed'))
|
|
||||||
} finally {
|
|
||||||
setNotificationLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNotificationCreate = () => {
|
|
||||||
setEditingNotificationConfig(null)
|
|
||||||
notificationForm.resetFields()
|
|
||||||
notificationForm.setFieldsValue({
|
|
||||||
type: 'telegram',
|
|
||||||
enabled: true,
|
|
||||||
config: {
|
|
||||||
botToken: '',
|
|
||||||
chatIds: []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setNotificationModalVisible(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNotificationEdit = (config: NotificationConfig) => {
|
|
||||||
setEditingNotificationConfig(config)
|
|
||||||
|
|
||||||
let botToken = ''
|
|
||||||
let chatIds = ''
|
|
||||||
|
|
||||||
if (config.config) {
|
|
||||||
if ('data' in config.config && config.config.data) {
|
|
||||||
const data = config.config.data as any
|
|
||||||
botToken = data.botToken || ''
|
|
||||||
if (data.chatIds) {
|
|
||||||
if (Array.isArray(data.chatIds)) {
|
|
||||||
chatIds = data.chatIds.join(',')
|
|
||||||
} else if (typeof data.chatIds === 'string') {
|
|
||||||
chatIds = data.chatIds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if ('botToken' in config.config) {
|
|
||||||
botToken = (config.config as any).botToken || ''
|
|
||||||
}
|
|
||||||
if ('chatIds' in config.config) {
|
|
||||||
const ids = (config.config as any).chatIds
|
|
||||||
if (Array.isArray(ids)) {
|
|
||||||
chatIds = ids.join(',')
|
|
||||||
} else if (typeof ids === 'string') {
|
|
||||||
chatIds = ids
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationForm.setFieldsValue({
|
|
||||||
type: config.type,
|
|
||||||
name: config.name,
|
|
||||||
enabled: config.enabled,
|
|
||||||
config: {
|
|
||||||
botToken: botToken,
|
|
||||||
chatIds: chatIds
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setNotificationModalVisible(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNotificationDelete = async (id: number) => {
|
|
||||||
try {
|
|
||||||
const response = await apiService.notifications.delete({ id })
|
|
||||||
if (response.data.code === 0) {
|
|
||||||
message.success(t('notificationSettings.deleteSuccess'))
|
|
||||||
fetchNotificationConfigs()
|
|
||||||
} else {
|
|
||||||
message.error(response.data.msg || t('notificationSettings.deleteFailed'))
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.message || t('notificationSettings.deleteFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNotificationUpdateEnabled = async (id: number, enabled: boolean) => {
|
|
||||||
try {
|
|
||||||
const response = await apiService.notifications.updateEnabled({ id, enabled })
|
|
||||||
if (response.data.code === 0) {
|
|
||||||
message.success(enabled ? t('notificationSettings.enableSuccess') : t('notificationSettings.disableSuccess'))
|
|
||||||
fetchNotificationConfigs()
|
|
||||||
} else {
|
|
||||||
message.error(response.data.msg || t('notificationSettings.updateStatusFailed'))
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.message || t('notificationSettings.updateStatusFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNotificationTest = async () => {
|
|
||||||
setTestLoading(true)
|
|
||||||
try {
|
|
||||||
const response = await apiService.notifications.test({ message: '这是一条测试消息' })
|
|
||||||
if (response.data.code === 0 && response.data.data) {
|
|
||||||
message.success(t('notificationSettings.testSuccess'))
|
|
||||||
} else {
|
|
||||||
message.error(response.data.msg || t('notificationSettings.testFailed'))
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.message || t('notificationSettings.testFailed'))
|
|
||||||
} finally {
|
|
||||||
setTestLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNotificationSubmit = async () => {
|
|
||||||
try {
|
|
||||||
const values = await notificationForm.validateFields()
|
|
||||||
|
|
||||||
const chatIds = typeof values.config.chatIds === 'string'
|
|
||||||
? values.config.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id)
|
|
||||||
: values.config.chatIds || []
|
|
||||||
|
|
||||||
const configData: NotificationConfigRequest | NotificationConfigUpdateRequest = {
|
|
||||||
type: values.type,
|
|
||||||
name: values.name,
|
|
||||||
enabled: values.enabled,
|
|
||||||
config: {
|
|
||||||
botToken: values.config.botToken,
|
|
||||||
chatIds: chatIds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editingNotificationConfig?.id) {
|
|
||||||
const updateData = {
|
|
||||||
...configData,
|
|
||||||
id: editingNotificationConfig.id
|
|
||||||
} as NotificationConfigUpdateRequest
|
|
||||||
|
|
||||||
const response = await apiService.notifications.update(updateData)
|
|
||||||
if (response.data.code === 0) {
|
|
||||||
message.success(t('notificationSettings.updateSuccess'))
|
|
||||||
setNotificationModalVisible(false)
|
|
||||||
fetchNotificationConfigs()
|
|
||||||
} else {
|
|
||||||
message.error(response.data.msg || t('notificationSettings.updateFailed'))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const response = await apiService.notifications.create(configData)
|
|
||||||
if (response.data.code === 0) {
|
|
||||||
message.success(t('notificationSettings.createSuccess'))
|
|
||||||
setNotificationModalVisible(false)
|
|
||||||
fetchNotificationConfigs()
|
|
||||||
} else {
|
|
||||||
message.error(response.data.msg || t('notificationSettings.createFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.errorFields) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
message.error(error.message || t('message.error'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notificationColumns = [
|
|
||||||
{
|
|
||||||
title: t('notificationSettings.configName'),
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('notificationSettings.type'),
|
|
||||||
dataIndex: 'type',
|
|
||||||
key: 'type',
|
|
||||||
render: (type: string) => <Tag color="blue">{type.toUpperCase()}</Tag>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('notificationSettings.status'),
|
|
||||||
dataIndex: 'enabled',
|
|
||||||
key: 'enabled',
|
|
||||||
render: (enabled: boolean) => (
|
|
||||||
<Tag color={enabled ? 'green' : 'default'}>
|
|
||||||
{enabled ? t('notificationSettings.enabledStatus') : t('notificationSettings.disabledStatus')}
|
|
||||||
</Tag>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('common.actions'),
|
|
||||||
key: 'action',
|
|
||||||
width: isMobile ? 120 : 200,
|
|
||||||
render: (_: any, record: NotificationConfig) => (
|
|
||||||
<Space size="small" wrap>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => handleNotificationEdit(record)}
|
|
||||||
>
|
|
||||||
{t('notificationSettings.edit')}
|
|
||||||
</Button>
|
|
||||||
<Switch
|
|
||||||
checked={record.enabled}
|
|
||||||
size="small"
|
|
||||||
onChange={(checked) => handleNotificationUpdateEnabled(record.id!, checked)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
icon={<SendOutlined />}
|
|
||||||
loading={testLoading}
|
|
||||||
onClick={handleNotificationTest}
|
|
||||||
>
|
|
||||||
{t('notificationSettings.test')}
|
|
||||||
</Button>
|
|
||||||
<Popconfirm
|
|
||||||
title={t('notificationSettings.deleteConfirm')}
|
|
||||||
onConfirm={() => handleNotificationDelete(record.id!)}
|
|
||||||
okText={t('common.confirm')}
|
|
||||||
cancelText={t('common.cancel')}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
danger
|
|
||||||
size="small"
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
>
|
|
||||||
{t('notificationSettings.delete')}
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
// ==================== 第三部分:Relayer配置 ====================
|
|
||||||
const fetchSystemConfig = async () => {
|
const fetchSystemConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiService.systemConfig.get()
|
const response = await apiService.systemConfig.get()
|
||||||
@@ -551,7 +304,7 @@ const SystemSettings: React.FC = () => {
|
|||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 第二部分:消息推送设置 */}
|
{/* 第二部分:消息推送设置(独立页面入口) */}
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
@@ -563,73 +316,24 @@ const SystemSettings: React.FC = () => {
|
|||||||
extra={
|
extra={
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<RightOutlined />}
|
||||||
onClick={handleNotificationCreate}
|
onClick={() => navigate('/system-settings/notification')}
|
||||||
>
|
>
|
||||||
{t('notificationSettings.addConfig')}
|
{t('notificationSettings.title')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table
|
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||||
columns={notificationColumns}
|
{t('notificationSettings.botConfig')}、{t('notificationSettings.templateConfig')}等请在独立页面中配置。
|
||||||
dataSource={notificationConfigs}
|
</Paragraph>
|
||||||
loading={notificationLoading}
|
<Button
|
||||||
rowKey="id"
|
type="link"
|
||||||
pagination={false}
|
icon={<RightOutlined />}
|
||||||
scroll={{ x: isMobile ? 600 : 'auto' }}
|
onClick={() => navigate('/system-settings/notification')}
|
||||||
/>
|
style={{ padding: 0 }}
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={editingNotificationConfig ? t('notificationSettings.editConfig') : t('notificationSettings.addConfig')}
|
|
||||||
open={notificationModalVisible}
|
|
||||||
onOk={handleNotificationSubmit}
|
|
||||||
onCancel={() => setNotificationModalVisible(false)}
|
|
||||||
width={isMobile ? '90%' : 600}
|
|
||||||
okText={t('common.confirm')}
|
|
||||||
cancelText={t('common.cancel')}
|
|
||||||
>
|
>
|
||||||
<Form
|
{t('notificationSettings.title')} →
|
||||||
form={notificationForm}
|
</Button>
|
||||||
layout="vertical"
|
|
||||||
>
|
|
||||||
<Form.Item
|
|
||||||
name="type"
|
|
||||||
label={t('notificationSettings.type')}
|
|
||||||
rules={[{ required: true, message: t('notificationSettings.typeRequired') }]}
|
|
||||||
>
|
|
||||||
<Input disabled value="telegram" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label={t('notificationSettings.configName')}
|
|
||||||
rules={[{ required: true, message: t('notificationSettings.configNameRequired') }]}
|
|
||||||
>
|
|
||||||
<Input placeholder={t('notificationSettings.configNamePlaceholder')} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="enabled"
|
|
||||||
label={t('notificationSettings.enabled')}
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item shouldUpdate={(prevValues, currentValues) => {
|
|
||||||
return prevValues.type !== currentValues.type ||
|
|
||||||
prevValues.config !== currentValues.config
|
|
||||||
}}>
|
|
||||||
{() => {
|
|
||||||
const currentType = notificationForm.getFieldValue('type') || 'telegram'
|
|
||||||
if (currentType === 'telegram') {
|
|
||||||
return <TelegramConfigForm form={notificationForm} />
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}}
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 第三部分:Relayer配置 */}
|
{/* 第三部分:Relayer配置 */}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ExclamationCircleOutlined
|
ExclamationCircleOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { apiClient } from '../services/api'
|
import { apiClient } from '../services/api'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
@@ -29,13 +30,14 @@ interface UpdateStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SystemUpdate: React.FC = () => {
|
const SystemUpdate: React.FC = () => {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
const [currentVersion, setCurrentVersion] = useState('')
|
const [currentVersion, setCurrentVersion] = useState('')
|
||||||
const [updateChecking, setUpdateChecking] = useState(false)
|
const [updateChecking, setUpdateChecking] = useState(false)
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
||||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({
|
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({
|
||||||
updating: false,
|
updating: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
message: '就绪',
|
message: '',
|
||||||
error: null
|
error: null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
setUpdateStatus({
|
setUpdateStatus({
|
||||||
updating: response.data.data.updating,
|
updating: response.data.data.updating,
|
||||||
progress: response.data.data.progress || 0,
|
progress: response.data.data.progress || 0,
|
||||||
message: response.data.data.message || '就绪',
|
message: response.data.data.message || '',
|
||||||
error: response.data.data.error || null
|
error: response.data.data.error || null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -83,15 +85,15 @@ const SystemUpdate: React.FC = () => {
|
|||||||
setUpdateInfo(data.data)
|
setUpdateInfo(data.data)
|
||||||
|
|
||||||
if (data.data.hasUpdate) {
|
if (data.data.hasUpdate) {
|
||||||
message.success(`发现新版本: ${data.data.latestVersion}`)
|
message.success(t('systemUpdate.hasNewVersion', { version: data.data.latestVersion }))
|
||||||
} else {
|
} else {
|
||||||
message.info('当前已是最新版本')
|
message.info(t('systemUpdate.alreadyLatest'))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
message.error(data.message || '检查更新失败')
|
message.error(data.message || t('systemUpdate.checkFailed'))
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message || '检查更新失败')
|
message.error(error.message || t('systemUpdate.checkFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setUpdateChecking(false)
|
setUpdateChecking(false)
|
||||||
}
|
}
|
||||||
@@ -99,25 +101,25 @@ const SystemUpdate: React.FC = () => {
|
|||||||
|
|
||||||
const handleExecuteUpdate = () => {
|
const handleExecuteUpdate = () => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认更新',
|
title: t('systemUpdate.confirmTitle'),
|
||||||
icon: <ExclamationCircleOutlined />,
|
icon: <ExclamationCircleOutlined />,
|
||||||
content: (
|
content: (
|
||||||
<div>
|
<div>
|
||||||
<p>确定要更新到版本 <strong>{updateInfo?.latestVersion}</strong> 吗?</p>
|
<p>{t('systemUpdate.confirmContent1', { version: updateInfo?.latestVersion })}</p>
|
||||||
<p>更新过程中系统将暂时不可用(约30-60秒)。</p>
|
<p>{t('systemUpdate.confirmContent2')}</p>
|
||||||
<p>更新完成后页面将自动刷新。</p>
|
<p>{t('systemUpdate.confirmContent3')}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
okText: '立即更新',
|
okText: t('systemUpdate.okText'),
|
||||||
okType: 'primary',
|
okType: 'primary',
|
||||||
cancelText: '取消',
|
cancelText: t('systemUpdate.cancelText'),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/update/update', {})
|
const response = await apiClient.post('/update/update', {})
|
||||||
const data = response.data
|
const data = response.data
|
||||||
|
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
message.success('更新已启动,请稍候...')
|
message.success(t('systemUpdate.updateStarted'))
|
||||||
|
|
||||||
// 开始轮询更新状态
|
// 开始轮询更新状态
|
||||||
const pollInterval = setInterval(async () => {
|
const pollInterval = setInterval(async () => {
|
||||||
@@ -138,9 +140,9 @@ const SystemUpdate: React.FC = () => {
|
|||||||
clearInterval(pollInterval)
|
clearInterval(pollInterval)
|
||||||
|
|
||||||
if (statusData.data.error) {
|
if (statusData.data.error) {
|
||||||
message.error(`更新失败: ${statusData.data.error}`)
|
message.error(t('systemUpdate.updateFailedWithMessage', { message: statusData.data.error }))
|
||||||
} else if (statusData.data.progress === 100) {
|
} else if (statusData.data.progress === 100) {
|
||||||
message.success('更新成功!页面将在3秒后刷新...')
|
message.success(t('systemUpdate.updateSuccessRefresh'))
|
||||||
setTimeout(() => window.location.reload(), 3000)
|
setTimeout(() => window.location.reload(), 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,19 +155,19 @@ const SystemUpdate: React.FC = () => {
|
|||||||
// 5分钟后停止轮询
|
// 5分钟后停止轮询
|
||||||
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000)
|
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000)
|
||||||
} else if (data.code === 403) {
|
} else if (data.code === 403) {
|
||||||
message.error('需要管理员权限才能执行更新')
|
message.error(t('systemUpdate.needAdmin'))
|
||||||
} else {
|
} else {
|
||||||
message.error(data.message || '启动更新失败')
|
message.error(data.message || t('systemUpdate.startFailed'))
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message || '启动更新失败')
|
message.error(error.message || t('systemUpdate.startFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString('zh-CN')
|
return new Date(dateString).toLocaleString(i18n.language === 'zh-CN' ? 'zh-CN' : i18n.language === 'zh-TW' ? 'zh-TW' : 'en')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -173,7 +175,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<CloudUploadOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
<CloudUploadOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
<span style={{ fontSize: '16px', fontWeight: 600 }}>系统更新</span>
|
<span style={{ fontSize: '16px', fontWeight: 600 }}>{t('systemUpdate.title')}</span>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
@@ -195,7 +197,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '13px', opacity: 0.9, marginBottom: '4px' }}>
|
<div style={{ fontSize: '13px', opacity: 0.9, marginBottom: '4px' }}>
|
||||||
当前版本
|
{t('systemUpdate.currentVersion')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '20px', fontWeight: 600 }}>
|
<div style={{ fontSize: '20px', fontWeight: 600 }}>
|
||||||
v{currentVersion || 'unknown'}
|
v{currentVersion || 'unknown'}
|
||||||
@@ -208,16 +210,16 @@ const SystemUpdate: React.FC = () => {
|
|||||||
{updateStatus.updating && (
|
{updateStatus.updating && (
|
||||||
<Alert
|
<Alert
|
||||||
message={
|
message={
|
||||||
<span style={{ fontSize: '15px', fontWeight: 500 }}>系统正在更新</span>
|
<span style={{ fontSize: '15px', fontWeight: 500 }}>{t('systemUpdate.updating')}</span>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<div style={{ marginTop: '12px' }}>
|
<div style={{ marginTop: '12px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
marginBottom: '12px',
|
marginBottom: '12px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#595959'
|
color: '#595959'
|
||||||
}}>
|
}}>
|
||||||
{updateStatus.message}
|
{updateStatus.message || t('systemUpdate.ready')}
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
<Progress
|
||||||
percent={updateStatus.progress}
|
percent={updateStatus.progress}
|
||||||
@@ -245,7 +247,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
|
|
||||||
{updateStatus.error && (
|
{updateStatus.error && (
|
||||||
<Alert
|
<Alert
|
||||||
message={<span style={{ fontSize: '15px', fontWeight: 500 }}>更新失败</span>}
|
message={<span style={{ fontSize: '15px', fontWeight: 500 }}>{t('systemUpdate.updateFailedTitle')}</span>}
|
||||||
description={
|
description={
|
||||||
<div style={{
|
<div style={{
|
||||||
marginTop: '8px',
|
marginTop: '8px',
|
||||||
@@ -281,14 +283,14 @@ const SystemUpdate: React.FC = () => {
|
|||||||
boxShadow: '0 2px 4px rgba(24, 144, 255, 0.2)'
|
boxShadow: '0 2px 4px rgba(24, 144, 255, 0.2)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
检查更新
|
{t('systemUpdate.checkUpdate')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{updateInfo && !updateInfo.hasUpdate && (
|
{updateInfo && !updateInfo.hasUpdate && (
|
||||||
<Alert
|
<Alert
|
||||||
message={
|
message={
|
||||||
<span style={{ fontSize: '15px', fontWeight: 500 }}>
|
<span style={{ fontSize: '15px', fontWeight: 500 }}>
|
||||||
当前已是最新版本
|
{t('systemUpdate.alreadyLatest')}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
type="success"
|
type="success"
|
||||||
@@ -326,7 +328,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
color: '#8c8c8c',
|
color: '#8c8c8c',
|
||||||
marginBottom: '6px'
|
marginBottom: '6px'
|
||||||
}}>
|
}}>
|
||||||
发现新版本
|
{t('systemUpdate.newVersionFound')}
|
||||||
</div>
|
</div>
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
<Tag
|
<Tag
|
||||||
@@ -341,15 +343,15 @@ const SystemUpdate: React.FC = () => {
|
|||||||
v{updateInfo.latestVersion}
|
v{updateInfo.latestVersion}
|
||||||
</Tag>
|
</Tag>
|
||||||
{updateInfo.prerelease && (
|
{updateInfo.prerelease && (
|
||||||
<Tag
|
<Tag
|
||||||
color="orange"
|
color="orange"
|
||||||
style={{
|
style={{
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
padding: '4px 12px',
|
padding: '4px 12px',
|
||||||
borderRadius: '4px'
|
borderRadius: '4px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Pre-release
|
{t('systemUpdate.prerelease')}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -367,7 +369,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
color: '#8c8c8c',
|
color: '#8c8c8c',
|
||||||
marginBottom: '4px'
|
marginBottom: '4px'
|
||||||
}}>
|
}}>
|
||||||
发布时间
|
{t('systemUpdate.publishedAt')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
@@ -385,7 +387,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
marginBottom: '8px',
|
marginBottom: '8px',
|
||||||
fontWeight: 500
|
fontWeight: 500
|
||||||
}}>
|
}}>
|
||||||
更新内容
|
{t('systemUpdate.releaseNotes')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
@@ -449,7 +451,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.4)'
|
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.4)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
立即升级到 v{updateInfo.latestVersion}
|
{t('systemUpdate.upgradeNow', { version: updateInfo.latestVersion })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -458,20 +460,20 @@ const SystemUpdate: React.FC = () => {
|
|||||||
{!updateStatus.updating && !(updateInfo && updateInfo.hasUpdate) && (
|
{!updateStatus.updating && !(updateInfo && updateInfo.hasUpdate) && (
|
||||||
<Alert
|
<Alert
|
||||||
message={
|
message={
|
||||||
<span style={{ fontSize: '15px', fontWeight: 500 }}>使用说明</span>
|
<span style={{ fontSize: '15px', fontWeight: 500 }}>{t('systemUpdate.usageTitle')}</span>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<ul style={{
|
<ul style={{
|
||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
paddingLeft: '20px',
|
paddingLeft: '20px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#595959',
|
color: '#595959',
|
||||||
lineHeight: '1.8'
|
lineHeight: '1.8'
|
||||||
}}>
|
}}>
|
||||||
<li>点击"检查更新"按钮检查是否有新版本</li>
|
<li>{t('systemUpdate.usage1')}</li>
|
||||||
<li>更新过程约需30-60秒,期间系统将暂时不可用</li>
|
<li>{t('systemUpdate.usage2')}</li>
|
||||||
<li>更新成功后页面将自动刷新</li>
|
<li>{t('systemUpdate.usage3')}</li>
|
||||||
<li>如果更新失败,系统会自动回滚到当前版本</li>
|
<li>{t('systemUpdate.usage4')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
type="info"
|
type="info"
|
||||||
|
|||||||
+198
-145
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, Input, Modal, Form, Radio, InputNumber, Switch, Divider, Spin } from 'antd'
|
import { Card, Table, Button, Space, Tag, Popconfirm, message, Input, Modal, Form, Radio, InputNumber, Switch, Divider, Spin, Empty, List, Tooltip } from 'antd'
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'
|
import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
@@ -212,25 +212,50 @@ const TemplateList: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: t('common.actions') || '操作',
|
title: t('common.actions') || '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: isMobile ? 120 : 200,
|
width: isMobile ? 120 : 120,
|
||||||
|
fixed: 'right' as const,
|
||||||
render: (_: any, record: CopyTradingTemplate) => (
|
render: (_: any, record: CopyTradingTemplate) => (
|
||||||
<Space size="small">
|
<Space size={4}>
|
||||||
<Button
|
<Tooltip title={t('common.edit') || '编辑'}>
|
||||||
type="link"
|
<div
|
||||||
size="small"
|
onClick={() => navigate(`/templates/edit/${record.id}`)}
|
||||||
icon={<EditOutlined />}
|
style={{
|
||||||
onClick={() => navigate(`/templates/edit/${record.id}`)}
|
display: 'flex',
|
||||||
>
|
alignItems: 'center',
|
||||||
{t('common.edit') || '编辑'}
|
justifyContent: 'center',
|
||||||
</Button>
|
width: '32px',
|
||||||
<Button
|
height: '32px',
|
||||||
type="link"
|
cursor: 'pointer',
|
||||||
size="small"
|
borderRadius: '6px',
|
||||||
icon={<CopyOutlined />}
|
transition: 'background-color 0.2s'
|
||||||
onClick={() => handleCopy(record)}
|
}}
|
||||||
>
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
{t('templateList.copy') || '复制'}
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
</Button>
|
>
|
||||||
|
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('templateList.copy') || '复制'}>
|
||||||
|
<div
|
||||||
|
onClick={() => handleCopy(record)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<CopyOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t('templateList.deleteConfirm') || '确定要删除这个模板吗?'}
|
title={t('templateList.deleteConfirm') || '确定要删除这个模板吗?'}
|
||||||
description={t('templateList.deleteConfirmDesc') || '删除后无法恢复,请确保没有跟单关系在使用该模板'}
|
description={t('templateList.deleteConfirmDesc') || '删除后无法恢复,请确保没有跟单关系在使用该模板'}
|
||||||
@@ -238,14 +263,24 @@ const TemplateList: React.FC = () => {
|
|||||||
okText={t('common.confirm') || '确定'}
|
okText={t('common.confirm') || '确定'}
|
||||||
cancelText={t('common.cancel') || '取消'}
|
cancelText={t('common.cancel') || '取消'}
|
||||||
>
|
>
|
||||||
<Button
|
<Tooltip title={t('common.delete') || '删除'}>
|
||||||
type="link"
|
<div
|
||||||
size="small"
|
style={{
|
||||||
danger
|
display: 'flex',
|
||||||
icon={<DeleteOutlined />}
|
alignItems: 'center',
|
||||||
>
|
justifyContent: 'center',
|
||||||
{t('common.delete') || '删除'}
|
width: '32px',
|
||||||
</Button>
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
@@ -254,26 +289,29 @@ const TemplateList: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
|
||||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
|
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('templateList.title') || '跟单模板管理'}</h2>
|
||||||
<h2 style={{ margin: 0 }}>{t('templateList.title') || '跟单模板管理'}</h2>
|
<Space size={8}>
|
||||||
<Space>
|
<Search
|
||||||
<Search
|
placeholder={t('templateList.searchPlaceholder') || '搜索模板名称'}
|
||||||
placeholder={t('templateList.searchPlaceholder') || '搜索模板名称'}
|
allowClear
|
||||||
allowClear
|
style={{ width: isMobile ? 120 : 200 }}
|
||||||
style={{ width: isMobile ? 150 : 250 }}
|
onSearch={setSearchText}
|
||||||
onSearch={setSearchText}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
/>
|
||||||
/>
|
<Tooltip title={t('templateList.addTemplate') || '新增模板'}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => navigate('/templates/add')}
|
onClick={() => navigate('/templates/add')}
|
||||||
>
|
size={isMobile ? 'middle' : 'large'}
|
||||||
{t('templateList.addTemplate') || '新增模板'}
|
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
|
||||||
</Button>
|
/>
|
||||||
</Space>
|
</Tooltip>
|
||||||
</div>
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
|
||||||
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
// 移动端卡片布局
|
// 移动端卡片布局
|
||||||
@@ -283,116 +321,133 @@ const TemplateList: React.FC = () => {
|
|||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
) : filteredTemplates.length === 0 ? (
|
) : filteredTemplates.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
<Empty description={t('templateList.noData') || '暂无模板数据'} />
|
||||||
{t('templateList.noData') || '暂无模板数据'}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<List
|
||||||
{filteredTemplates.map((template) => {
|
dataSource={filteredTemplates}
|
||||||
const date = new Date(template.createdAt)
|
renderItem={(template) => {
|
||||||
const formattedDate = date.toLocaleString(i18n.language || 'zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={template.id}
|
key={template.id}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: '12px',
|
marginBottom: '10px',
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
borderRadius: '10px',
|
||||||
border: '1px solid #e8e8e8'
|
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
overflow: 'hidden'
|
||||||
}}
|
}}
|
||||||
bodyStyle={{ padding: '16px' }}
|
bodyStyle={{ padding: '0' }}
|
||||||
>
|
>
|
||||||
{/* 模板名称和模式 */}
|
{/* 头部区域 - 模板名称 */}
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{
|
||||||
<div style={{
|
padding: '10px 12px',
|
||||||
fontSize: '16px',
|
background: 'var(--ant-color-primary, #1677ff)',
|
||||||
fontWeight: 'bold',
|
color: '#fff'
|
||||||
marginBottom: '8px',
|
}}>
|
||||||
color: '#1890ff'
|
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px' }}>
|
||||||
}}>
|
|
||||||
{template.templateName}
|
{template.templateName}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
<div style={{ fontSize: '12px', opacity: '0.9' }}>
|
||||||
<Tag color={template.copyMode === 'RATIO' ? 'blue' : 'green'}>
|
|
||||||
{template.copyMode === 'RATIO' ? (t('templateList.ratioMode') || '比例模式') : (t('templateList.fixedAmountMode') || '固定金额模式')}
|
|
||||||
</Tag>
|
|
||||||
<Tag color={template.supportSell ? 'green' : 'red'}>
|
|
||||||
{template.supportSell ? (t('templateList.supportSell') || '跟单卖出') : (t('templateList.notSupportSell') || '不跟单卖出')}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
|
||||||
|
|
||||||
{/* 跟单配置 */}
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('templateList.copyConfig') || '跟单配置'}</div>
|
|
||||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
|
||||||
{template.copyMode === 'RATIO'
|
{template.copyMode === 'RATIO'
|
||||||
? `${t('templateList.ratio') || '比例'} ${template.copyRatio}x`
|
? `${t('templateList.ratioMode') || '比例模式'} ${(parseFloat(template.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%`
|
||||||
: template.fixedAmount
|
: `${t('templateList.fixedAmountMode') || '固定金额'} ${formatUSDC(template.fixedAmount || '0')} USDC`
|
||||||
? `${t('templateList.fixedAmount') || '固定'} ${formatUSDC(template.fixedAmount)} USDC`
|
|
||||||
: '-'
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 其他配置信息 */}
|
{/* 配置信息区域 */}
|
||||||
{template.copyMode === 'RATIO' && (
|
<div style={{
|
||||||
<div style={{ marginBottom: '12px' }}>
|
padding: '8px 12px',
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('templateList.amountLimit') || '金额限制'}</div>
|
backgroundColor: '#fafafa',
|
||||||
<div style={{ fontSize: '13px', color: '#333' }}>
|
borderBottom: '1px solid #f0f0f0',
|
||||||
{template.maxOrderSize && (
|
minHeight: '42px',
|
||||||
<span>{t('templateList.max') || '最大'}: {formatUSDC(template.maxOrderSize)} USDC</span>
|
display: 'flex',
|
||||||
)}
|
alignItems: 'center'
|
||||||
{template.maxOrderSize && template.minOrderSize && <span> | </span>}
|
}}>
|
||||||
{template.minOrderSize && (
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||||
<span>{t('templateList.min') || '最小'}: {formatUSDC(template.minOrderSize)} USDC</span>
|
<div>
|
||||||
)}
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
|
||||||
{!template.maxOrderSize && !template.minOrderSize && <span style={{ color: '#999' }}>{t('templateList.notSet') || '未设置'}</span>}
|
{t('templateList.supportSell') || '跟单卖出'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', fontWeight: '500' }}>
|
||||||
|
<Tag color={template.supportSell ? 'green' : 'red'} style={{ margin: 0, fontSize: '10px' }}>
|
||||||
|
{template.supportSell ? (t('common.yes') || '是') : (t('common.no') || '否')}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
|
||||||
|
{t('templateList.maxDailyOrders') || '每日最大'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', fontWeight: '500', color: '#1890ff' }}>
|
||||||
|
{template.maxDailyOrders} {t('common.orders') || '单'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 金额限制区域(仅比例模式显示) */}
|
||||||
|
{template.copyMode === 'RATIO' && (
|
||||||
|
<div style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#8c8c8c',
|
||||||
|
borderBottom: '1px solid #f0f0f0'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: '#d48806' }}>{t('templateList.amountLimit') || '金额限制'}: </span>
|
||||||
|
{template.maxOrderSize && (
|
||||||
|
<span>{t('templateList.max') || '最大'} {formatUSDC(template.maxOrderSize)} USDC</span>
|
||||||
|
)}
|
||||||
|
{template.maxOrderSize && template.minOrderSize && <span> | </span>}
|
||||||
|
{template.minOrderSize && (
|
||||||
|
<span>{t('templateList.min') || '最小'} {formatUSDC(template.minOrderSize)} USDC</span>
|
||||||
|
)}
|
||||||
|
{!template.maxOrderSize && !template.minOrderSize && <span style={{ color: '#bfbfbf' }}>{t('templateList.notSet') || '未设置'}</span>}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('templateList.otherConfig') || '其他配置'}</div>
|
|
||||||
<div style={{ fontSize: '13px', color: '#333' }}>
|
|
||||||
{t('templateList.maxDailyOrders') || '每日最大订单'}: {template.maxDailyOrders} | {t('templateList.priceTolerance') || '价格容忍度'}: {template.priceTolerance}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 创建时间 */}
|
{/* 创建时间 */}
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{
|
||||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
padding: '6px 12px',
|
||||||
{t('common.createdAt') || '创建时间'}: {formattedDate}
|
fontSize: '11px',
|
||||||
</div>
|
color: '#8c8c8c'
|
||||||
|
}}>
|
||||||
|
{t('common.createdAt') || '创建时间'}: {new Date(template.createdAt).toLocaleString(i18n.language || 'zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 图标操作栏 */}
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
<div style={{
|
||||||
<Button
|
padding: '8px 12px',
|
||||||
type="primary"
|
display: 'flex',
|
||||||
size="small"
|
justifyContent: 'space-around',
|
||||||
icon={<EditOutlined />}
|
alignItems: 'center'
|
||||||
onClick={() => navigate(`/templates/edit/${template.id}`)}
|
}}>
|
||||||
style={{ flex: 1, minWidth: '80px' }}
|
<Tooltip title={t('common.edit') || '编辑'}>
|
||||||
>
|
<div
|
||||||
{t('common.edit') || '编辑'}
|
onClick={() => navigate(`/templates/edit/${template.id}`)}
|
||||||
</Button>
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
|
||||||
<Button
|
>
|
||||||
size="small"
|
<EditOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
icon={<CopyOutlined />}
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.edit') || '编辑'}</span>
|
||||||
onClick={() => handleCopy(template)}
|
</div>
|
||||||
style={{ flex: 1, minWidth: '80px' }}
|
</Tooltip>
|
||||||
>
|
|
||||||
{t('templateList.copy') || '复制'}
|
<Tooltip title={t('templateList.copy') || '复制'}>
|
||||||
</Button>
|
<div
|
||||||
|
onClick={() => handleCopy(template)}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
<CopyOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('templateList.copy') || '复制'}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t('templateList.deleteConfirm') || '确定要删除这个模板吗?'}
|
title={t('templateList.deleteConfirm') || '确定要删除这个模板吗?'}
|
||||||
description={t('templateList.deleteConfirmDesc') || '删除后无法恢复,请确保没有跟单关系在使用该模板'}
|
description={t('templateList.deleteConfirmDesc') || '删除后无法恢复,请确保没有跟单关系在使用该模板'}
|
||||||
@@ -400,20 +455,18 @@ const TemplateList: React.FC = () => {
|
|||||||
okText={t('common.confirm') || '确定'}
|
okText={t('common.confirm') || '确定'}
|
||||||
cancelText={t('common.cancel') || '取消'}
|
cancelText={t('common.cancel') || '取消'}
|
||||||
>
|
>
|
||||||
<Button
|
<Tooltip title={t('common.delete') || '删除'}>
|
||||||
danger
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
size="small"
|
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
|
||||||
icon={<DeleteOutlined />}
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete') || '删除'}</span>
|
||||||
style={{ flex: 1, minWidth: '80px' }}
|
</div>
|
||||||
>
|
</Tooltip>
|
||||||
{t('common.delete') || '删除'}
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
}}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+155
-37
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input } from 'antd'
|
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input, List, Empty, Tooltip, Spin } from 'antd'
|
||||||
import { PlusOutlined, ReloadOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
import { PlusOutlined, ReloadOutlined, DeleteOutlined, EditOutlined, UserOutlined, KeyOutlined } from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
@@ -219,46 +219,164 @@ const UserList: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
<Title level={isMobile ? 4 : 3} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}>{t('userList.title') || '用户管理'}</Title>
|
||||||
<Title level={4} style={{ margin: 0 }}>{t('userList.title') || '用户管理'}</Title>
|
<Space size={8} wrap>
|
||||||
<Space>
|
<Button
|
||||||
<Button
|
icon={<KeyOutlined />}
|
||||||
icon={<EditOutlined />}
|
onClick={() => setUpdateOwnPasswordModalVisible(true)}
|
||||||
onClick={() => setUpdateOwnPasswordModalVisible(true)}
|
size={isMobile ? 'middle' : 'large'}
|
||||||
>
|
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px' }}
|
||||||
{t('userList.updateMyPassword') || '修改我的密码'}
|
>
|
||||||
</Button>
|
{isMobile ? (t('userList.updateMyPassword') || '改密') : (t('userList.updateMyPassword') || '修改我的密码')}
|
||||||
<Button
|
</Button>
|
||||||
icon={<ReloadOutlined />}
|
<Button
|
||||||
onClick={fetchUsers}
|
icon={<ReloadOutlined />}
|
||||||
loading={loading}
|
onClick={fetchUsers}
|
||||||
>
|
loading={loading}
|
||||||
{t('common.refresh') || '刷新'}
|
size={isMobile ? 'middle' : 'large'}
|
||||||
</Button>
|
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px' }}
|
||||||
{isDefaultUser && (
|
>
|
||||||
|
{t('common.refresh') || '刷新'}
|
||||||
|
</Button>
|
||||||
|
{isDefaultUser && (
|
||||||
|
<Tooltip title={t('userList.addUser') || '新增用户'}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => setCreateModalVisible(true)}
|
onClick={() => setCreateModalVisible(true)}
|
||||||
>
|
size={isMobile ? 'middle' : 'large'}
|
||||||
{t('userList.addUser') || '新增用户'}
|
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
|
||||||
</Button>
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
|
||||||
|
{isMobile ? (
|
||||||
|
// 移动端卡片布局
|
||||||
|
<div>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<Empty description={t('userList.noData') || '暂无用户数据'} />
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
dataSource={users}
|
||||||
|
renderItem={(user) => (
|
||||||
|
<Card
|
||||||
|
key={user.id}
|
||||||
|
style={{
|
||||||
|
marginBottom: '10px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: '0' }}
|
||||||
|
>
|
||||||
|
{/* 头部区域 - 用户名 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: 'var(--ant-color-primary, #1677ff)',
|
||||||
|
color: '#fff'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<UserOutlined style={{ fontSize: '14px' }} />
|
||||||
|
<span>{user.username}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', opacity: '0.9' }}>
|
||||||
|
ID: {user.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 角色信息区域 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
minHeight: '42px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
|
||||||
|
{t('userList.role') || '角色'}
|
||||||
|
</div>
|
||||||
|
<Tag color={user.isDefault ? 'red' : 'blue'} style={{ margin: 0 }}>
|
||||||
|
{user.isDefault ? (t('userList.defaultAccount') || '默认账户') : (t('userList.normalUser') || '普通用户')}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
|
||||||
|
{t('common.createdAt') || '创建时间'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', fontWeight: '500', color: '#666' }}>
|
||||||
|
{new Date(user.createdAt).toLocaleDateString(i18n.language || 'zh-CN')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作区域(仅管理员可见) */}
|
||||||
|
{isDefaultUser && !user.isDefault && (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<Tooltip title={t('userList.updatePassword') || '修改密码'}>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUser(user)
|
||||||
|
setUpdatePasswordModalVisible(true)
|
||||||
|
}}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
<KeyOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('userList.updatePassword') || '改密'}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Popconfirm
|
||||||
|
title={t('userList.deleteConfirm') || '确定要删除这个用户吗?'}
|
||||||
|
onConfirm={() => handleDelete(user)}
|
||||||
|
okText={t('common.confirm') || '确定'}
|
||||||
|
cancelText={t('common.cancel') || '取消'}
|
||||||
|
>
|
||||||
|
<Tooltip title={t('common.delete') || '删除'}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
|
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete') || '删除'}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={users}
|
dataSource={users}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={{
|
||||||
pageSize: isMobile ? 10 : 20,
|
pageSize: isMobile ? 10 : 20,
|
||||||
showSizeChanger: !isMobile,
|
showSizeChanger: !isMobile,
|
||||||
showTotal: (total) => t('userList.total', { total }) || `共 ${total} 条`
|
showTotal: (total) => t('userList.total', { total }) || `共 ${total} 条`
|
||||||
}}
|
}}
|
||||||
scroll={isMobile ? { x: 600 } : undefined}
|
scroll={isMobile ? { x: 600 } : undefined}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 创建用户弹窗 */}
|
{/* 创建用户弹窗 */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosInstance, AxiosError } from 'axios'
|
import axios, { AxiosInstance, AxiosError } from 'axios'
|
||||||
import type { ApiResponse, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest } from '../types'
|
import type { ApiResponse, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest, NotificationTemplate, TemplateTypeInfo, TemplateVariablesResponse } from '../types'
|
||||||
import { getToken, setToken, removeToken } from '../utils'
|
import { getToken, setToken, removeToken } from '../utils'
|
||||||
import { wsManager } from './websocket'
|
import { wsManager } from './websocket'
|
||||||
import i18n from '../i18n/config'
|
import i18n from '../i18n/config'
|
||||||
@@ -215,13 +215,28 @@ export const apiService = {
|
|||||||
/**
|
/**
|
||||||
* 导入账户
|
* 导入账户
|
||||||
*/
|
*/
|
||||||
import: (data: any) =>
|
import: (data: any) =>
|
||||||
apiClient.post<ApiResponse<any>>('/accounts/import', data),
|
apiClient.post<ApiResponse<any>>('/accounts/import', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账户设置状态
|
||||||
|
*/
|
||||||
|
checkSetupStatus: (accountId: number) =>
|
||||||
|
apiClient.post<ApiResponse<any>>('/accounts/check-setup-status', { accountId }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行设置步骤(步骤1 返回跳转 URL,步骤2/3 由后端执行)
|
||||||
|
*/
|
||||||
|
executeSetupStep: (accountId: number, step: number) =>
|
||||||
|
apiClient.post<ApiResponse<{ success: boolean; redirectUrl?: string; transactionHash?: string }>>(
|
||||||
|
'/accounts/execute-setup-step',
|
||||||
|
{ accountId, step }
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新账户
|
* 更新账户
|
||||||
*/
|
*/
|
||||||
update: (data: any) =>
|
update: (data: any) =>
|
||||||
apiClient.post<ApiResponse<any>>('/accounts/update', data),
|
apiClient.post<ApiResponse<any>>('/accounts/update', data),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -431,7 +446,7 @@ export const apiService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略 API
|
* 加密价差策略 API
|
||||||
*/
|
*/
|
||||||
cryptoTailStrategy: {
|
cryptoTailStrategy: {
|
||||||
list: (data: { accountId?: number; enabled?: boolean } = {}) =>
|
list: (data: { accountId?: number; enabled?: boolean } = {}) =>
|
||||||
@@ -447,8 +462,9 @@ export const apiService = {
|
|||||||
maxPrice?: string
|
maxPrice?: string
|
||||||
amountMode: string
|
amountMode: string
|
||||||
amountValue: string
|
amountValue: string
|
||||||
minSpreadMode?: string
|
spreadMode?: string
|
||||||
minSpreadValue?: string | null
|
spreadValue?: string | null
|
||||||
|
spreadDirection?: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}) =>
|
}) =>
|
||||||
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/create', data),
|
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/create', data),
|
||||||
@@ -461,8 +477,9 @@ export const apiService = {
|
|||||||
maxPrice?: string
|
maxPrice?: string
|
||||||
amountMode?: string
|
amountMode?: string
|
||||||
amountValue?: string
|
amountValue?: string
|
||||||
minSpreadMode?: string
|
spreadMode?: string
|
||||||
minSpreadValue?: string | null
|
spreadValue?: string | null
|
||||||
|
spreadDirection?: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}) =>
|
}) =>
|
||||||
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/update', data),
|
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/update', data),
|
||||||
@@ -477,12 +494,26 @@ export const apiService = {
|
|||||||
endDate?: number
|
endDate?: number
|
||||||
}) =>
|
}) =>
|
||||||
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyTriggerDto[]; total: number }>>('/crypto-tail-strategy/triggers', data),
|
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyTriggerDto[]; total: number }>>('/crypto-tail-strategy/triggers', data),
|
||||||
|
pnlCurve: (data: import('../types').CryptoTailPnlCurveRequest) =>
|
||||||
|
apiClient.post<ApiResponse<import('../types').CryptoTailPnlCurveResponse>>('/crypto-tail-strategy/pnl-curve', data),
|
||||||
marketOptions: () =>
|
marketOptions: () =>
|
||||||
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
|
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
|
||||||
autoMinSpread: (data: { intervalSeconds: number }) =>
|
autoMinSpread: (data: { intervalSeconds: number }) =>
|
||||||
apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data)
|
apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data),
|
||||||
|
monitorInit: (data: { strategyId: number; periodStartUnix?: number }) =>
|
||||||
|
apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', data),
|
||||||
|
manualOrder: (data: {
|
||||||
|
strategyId: number
|
||||||
|
periodStartUnix: number
|
||||||
|
direction: 'UP' | 'DOWN'
|
||||||
|
price: string
|
||||||
|
size: string
|
||||||
|
marketTitle: string
|
||||||
|
tokenIds: string[]
|
||||||
|
}) =>
|
||||||
|
apiClient.post<ApiResponse<import('../types').CryptoTailManualOrderResponse>>('/crypto-tail-strategy/manual-order', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订单管理 API
|
* 订单管理 API
|
||||||
*/
|
*/
|
||||||
@@ -655,7 +686,51 @@ export const apiService = {
|
|||||||
* 获取 Telegram Chat IDs
|
* 获取 Telegram Chat IDs
|
||||||
*/
|
*/
|
||||||
getTelegramChatIds: (data: { botToken: string }) =>
|
getTelegramChatIds: (data: { botToken: string }) =>
|
||||||
apiClient.post<ApiResponse<string[]>>('/system/notifications/telegram/get-chat-ids', data)
|
apiClient.post<ApiResponse<string[]>>('/system/notifications/telegram/get-chat-ids', data),
|
||||||
|
|
||||||
|
// ==================== 模板相关 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有模板类型
|
||||||
|
*/
|
||||||
|
getTemplateTypes: () =>
|
||||||
|
apiClient.post<ApiResponse<TemplateTypeInfo[]>>('/system/notifications/templates/types', {}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有模板列表
|
||||||
|
*/
|
||||||
|
getTemplates: () =>
|
||||||
|
apiClient.post<ApiResponse<NotificationTemplate[]>>('/system/notifications/templates/list', {}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个模板详情
|
||||||
|
*/
|
||||||
|
getTemplateDetail: (data: { templateType: string }) =>
|
||||||
|
apiClient.post<ApiResponse<NotificationTemplate>>('/system/notifications/templates/detail', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模板可用变量
|
||||||
|
*/
|
||||||
|
getTemplateVariables: (data: { templateType: string }) =>
|
||||||
|
apiClient.post<ApiResponse<TemplateVariablesResponse>>('/system/notifications/templates/variables', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模板
|
||||||
|
*/
|
||||||
|
updateTemplate: (data: { templateType: string; templateContent: string }) =>
|
||||||
|
apiClient.post<ApiResponse<NotificationTemplate>>('/system/notifications/templates/update', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置模板为默认
|
||||||
|
*/
|
||||||
|
resetTemplate: (data: { templateType: string }) =>
|
||||||
|
apiClient.post<ApiResponse<NotificationTemplate>>('/system/notifications/templates/reset', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送模板测试消息
|
||||||
|
*/
|
||||||
|
testTemplate: (data: { templateType: string; templateContent?: string }) =>
|
||||||
|
apiClient.post<ApiResponse<boolean>>('/system/notifications/templates/test', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+215
-11
@@ -580,12 +580,13 @@ export interface OrderMessage {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 订单详情(通过 API 获取)
|
* 订单详情(通过 API 获取)
|
||||||
|
* price 为订单限价,avgFilledPrice 为平均成交价(有成交时优先用于展示)
|
||||||
*/
|
*/
|
||||||
export interface OrderDetail {
|
export interface OrderDetail {
|
||||||
id: string // 订单 ID
|
id: string // 订单 ID
|
||||||
market: string // 市场 ID (condition ID)
|
market: string // 市场 ID (condition ID)
|
||||||
side: string // BUY/SELL
|
side: string // BUY/SELL
|
||||||
price: string // 价格
|
price: string // 订单限价
|
||||||
size: string // 订单大小
|
size: string // 订单大小
|
||||||
filled: string // 已成交数量
|
filled: string // 已成交数量
|
||||||
status: string // 订单状态
|
status: string // 订单状态
|
||||||
@@ -593,6 +594,7 @@ export interface OrderDetail {
|
|||||||
marketName?: string // 市场名称
|
marketName?: string // 市场名称
|
||||||
marketSlug?: string // 市场 slug
|
marketSlug?: string // 市场 slug
|
||||||
marketIcon?: string // 市场图标
|
marketIcon?: string // 市场图标
|
||||||
|
avgFilledPrice?: string // 平均成交价(有成交时优先展示)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -932,9 +934,9 @@ export interface SystemConfig {
|
|||||||
builderApiKeyConfigured: boolean
|
builderApiKeyConfigured: boolean
|
||||||
builderSecretConfigured: boolean
|
builderSecretConfigured: boolean
|
||||||
builderPassphraseConfigured: boolean
|
builderPassphraseConfigured: boolean
|
||||||
builderApiKeyDisplay?: string // Builder API Key 显示值(部分显示)
|
builderApiKeyDisplay?: string // Builder API Key 显示值(完整)
|
||||||
builderSecretDisplay?: string // Builder Secret 显示值(部分显示)
|
builderSecretDisplay?: string // Builder Secret 显示值(完整)
|
||||||
builderPassphraseDisplay?: string // Builder Passphrase 显示值(部分显示)
|
builderPassphraseDisplay?: string // Builder Passphrase 显示值(完整)
|
||||||
autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启)
|
autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1036,7 +1038,7 @@ export interface BacktestTaskDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略
|
* 加密价差策略
|
||||||
*/
|
*/
|
||||||
export interface CryptoTailStrategyDto {
|
export interface CryptoTailStrategyDto {
|
||||||
id: number
|
id: number
|
||||||
@@ -1051,10 +1053,12 @@ export interface CryptoTailStrategyDto {
|
|||||||
maxPrice: string
|
maxPrice: string
|
||||||
amountMode: string
|
amountMode: string
|
||||||
amountValue: string
|
amountValue: string
|
||||||
/** 最小价差模式: NONE, FIXED, AUTO */
|
/** 价差模式: NONE, FIXED, AUTO */
|
||||||
minSpreadMode?: string
|
spreadMode?: string
|
||||||
/** 最小价差数值(FIXED 时必填;AUTO 时可为计算值) */
|
/** 价差数值 */
|
||||||
minSpreadValue?: string | null
|
spreadValue?: string | null
|
||||||
|
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
|
||||||
|
spreadDirection?: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
lastTriggerAt?: number
|
lastTriggerAt?: number
|
||||||
/** 已实现总收益 USDC */
|
/** 已实现总收益 USDC */
|
||||||
@@ -1074,7 +1078,7 @@ export interface CryptoTailAutoMinSpreadResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略触发记录
|
* 加密价差策略触发记录
|
||||||
*/
|
*/
|
||||||
export interface CryptoTailStrategyTriggerDto {
|
export interface CryptoTailStrategyTriggerDto {
|
||||||
id: number
|
id: number
|
||||||
@@ -1095,8 +1099,35 @@ export interface CryptoTailStrategyTriggerDto {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 收益曲线请求 */
|
||||||
|
export interface CryptoTailPnlCurveRequest {
|
||||||
|
strategyId: number
|
||||||
|
startDate?: number
|
||||||
|
endDate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收益曲线单点 */
|
||||||
|
export interface CryptoTailPnlCurvePoint {
|
||||||
|
timestamp: number
|
||||||
|
cumulativePnl: string
|
||||||
|
pointPnl: string
|
||||||
|
settledCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收益曲线响应 */
|
||||||
|
export interface CryptoTailPnlCurveResponse {
|
||||||
|
strategyId: number
|
||||||
|
strategyName: string
|
||||||
|
totalRealizedPnl: string
|
||||||
|
settledCount: number
|
||||||
|
winCount: number
|
||||||
|
winRate: string | null
|
||||||
|
maxDrawdown: string | null
|
||||||
|
curveData: CryptoTailPnlCurvePoint[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略市场选项
|
* 加密价差策略市场选项
|
||||||
*/
|
*/
|
||||||
export interface CryptoTailMarketOptionDto {
|
export interface CryptoTailMarketOptionDto {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -1105,3 +1136,176 @@ export interface CryptoTailMarketOptionDto {
|
|||||||
periodStartUnix: number
|
periodStartUnix: number
|
||||||
endDate?: string
|
endDate?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控初始化响应
|
||||||
|
*/
|
||||||
|
export interface CryptoTailMonitorInitResponse {
|
||||||
|
/** 策略ID */
|
||||||
|
strategyId: number
|
||||||
|
/** 策略名称 */
|
||||||
|
name: string
|
||||||
|
/** 账户ID */
|
||||||
|
accountId: number
|
||||||
|
/** 账户名称 */
|
||||||
|
accountName: string
|
||||||
|
/** 市场 slug 前缀 */
|
||||||
|
marketSlugPrefix: string
|
||||||
|
/** 市场标题 */
|
||||||
|
marketTitle: string
|
||||||
|
/** 周期秒数 (300=5m, 900=15m) */
|
||||||
|
intervalSeconds: number
|
||||||
|
/** 当前周期开始时间 (Unix 秒) */
|
||||||
|
periodStartUnix: number
|
||||||
|
/** 时间窗口开始秒数 */
|
||||||
|
windowStartSeconds: number
|
||||||
|
/** 时间窗口结束秒数 */
|
||||||
|
windowEndSeconds: number
|
||||||
|
/** 最低价格 */
|
||||||
|
minPrice: string
|
||||||
|
/** 最高价格 */
|
||||||
|
maxPrice: string
|
||||||
|
/** 最小价差模式: NONE, FIXED, AUTO */
|
||||||
|
minSpreadMode: string
|
||||||
|
/** 价差方向: MIN(显示周期内最小价差), MAX(显示周期内最大价差) */
|
||||||
|
spreadDirection?: string
|
||||||
|
/** 最小价差数值 (FIXED 时有值) */
|
||||||
|
minSpreadValue?: string
|
||||||
|
/** 自动计算的最小价差 (Up方向) */
|
||||||
|
autoMinSpreadUp?: string
|
||||||
|
/** 自动计算的最小价差 (Down方向) */
|
||||||
|
autoMinSpreadDown?: string
|
||||||
|
/** BTC 开盘价 USDC(来自币安 K 线) */
|
||||||
|
openPriceBtc?: string
|
||||||
|
/** Up tokenId */
|
||||||
|
tokenIdUp?: string
|
||||||
|
/** Down tokenId */
|
||||||
|
tokenIdDown?: string
|
||||||
|
/** 当前时间 (毫秒时间戳) */
|
||||||
|
currentTimestamp: number
|
||||||
|
/** 是否启用 */
|
||||||
|
enabled: boolean
|
||||||
|
/** 投入金额模式: FIXED or RATIO */
|
||||||
|
amountMode?: string
|
||||||
|
/** 投入金额数值 */
|
||||||
|
amountValue?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控实时推送数据
|
||||||
|
*/
|
||||||
|
export interface CryptoTailMonitorPushData {
|
||||||
|
/** 策略ID */
|
||||||
|
strategyId: number
|
||||||
|
/** 推送时间 (毫秒时间戳) */
|
||||||
|
timestamp: number
|
||||||
|
/** 当前周期开始时间 (Unix 秒) */
|
||||||
|
periodStartUnix: number
|
||||||
|
/** 当前周期市场标题(周期切换时更新) */
|
||||||
|
marketTitle?: string
|
||||||
|
/** 当前价格 (Up方向,来自订单簿) */
|
||||||
|
currentPriceUp?: string
|
||||||
|
/** 当前价格 (Down方向,来自订单簿) */
|
||||||
|
currentPriceDown?: string
|
||||||
|
/** 当前价差 (Up方向: 1 - currentPriceUp) */
|
||||||
|
spreadUp?: string
|
||||||
|
/** 当前价差 (Down方向: currentPriceUp) */
|
||||||
|
spreadDown?: string
|
||||||
|
/** 最小价差线 (Up方向,USDC) */
|
||||||
|
minSpreadLineUp?: string
|
||||||
|
/** 最小价差线 (Down方向,USDC) */
|
||||||
|
minSpreadLineDown?: string
|
||||||
|
/** BTC 开盘价 USDC */
|
||||||
|
openPriceBtc?: string
|
||||||
|
/** BTC 最新价 USDC */
|
||||||
|
currentPriceBtc?: string
|
||||||
|
/** BTC 价差 USDC(currentPriceBtc - openPriceBtc) */
|
||||||
|
spreadBtc?: string
|
||||||
|
/** 周期剩余秒数 */
|
||||||
|
remainingSeconds: number
|
||||||
|
/** 是否在时间窗口内 */
|
||||||
|
inTimeWindow: boolean
|
||||||
|
/** 是否在价格区间内 (Up方向) */
|
||||||
|
inPriceRangeUp: boolean
|
||||||
|
/** 是否在价格区间内 (Down方向) */
|
||||||
|
inPriceRangeDown: boolean
|
||||||
|
/** 是否已触发 */
|
||||||
|
triggered: boolean
|
||||||
|
/** 触发方向: UP, DOWN, null */
|
||||||
|
triggerDirection?: string
|
||||||
|
/** 周期是否已结束 */
|
||||||
|
periodEnded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CryptoTailManualOrderResponse {
|
||||||
|
/** 是否成功 */
|
||||||
|
success: boolean
|
||||||
|
/** 订单ID */
|
||||||
|
orderId?: string
|
||||||
|
/** 提示消息 */
|
||||||
|
message: string
|
||||||
|
/** 下单详情 */
|
||||||
|
orderDetails?: ManualOrderDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualOrderDetails {
|
||||||
|
/** 策略ID */
|
||||||
|
strategyId: number
|
||||||
|
/** 方向 */
|
||||||
|
direction: string
|
||||||
|
/** 下单价格 */
|
||||||
|
price: string
|
||||||
|
/** 下单数量 */
|
||||||
|
size: string
|
||||||
|
/** 总金额 */
|
||||||
|
totalAmount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 消息模板相关类型 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息模板
|
||||||
|
*/
|
||||||
|
export interface NotificationTemplate {
|
||||||
|
id?: number
|
||||||
|
templateType: string // 模板类型
|
||||||
|
templateContent: string // 模板内容
|
||||||
|
isDefault: boolean // 是否使用默认模板
|
||||||
|
createdAt?: number
|
||||||
|
updatedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板类型信息
|
||||||
|
*/
|
||||||
|
export interface TemplateTypeInfo {
|
||||||
|
type: string // 模板类型
|
||||||
|
name: string // 类型名称
|
||||||
|
description: string // 类型描述
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板变量
|
||||||
|
*/
|
||||||
|
export interface TemplateVariable {
|
||||||
|
key: string // 变量名
|
||||||
|
category: string // 分类
|
||||||
|
sortOrder: number // 排序顺序
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板变量分类
|
||||||
|
*/
|
||||||
|
export interface TemplateVariableCategory {
|
||||||
|
key: string // 分类 key
|
||||||
|
sortOrder: number // 排序顺序
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板变量列表响应
|
||||||
|
*/
|
||||||
|
export interface TemplateVariablesResponse {
|
||||||
|
templateType: string // 模板类型
|
||||||
|
categories: TemplateVariableCategory[] // 分类列表
|
||||||
|
variables: TemplateVariable[] // 变量列表
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
"description": "Utility scripts for Polyhermes",
|
"description": "Utility scripts for Polyhermes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"unwrap-wcol": "node unwrap-wcol.js",
|
||||||
"get-order-detail": "node get-order-detail.js",
|
"get-order-detail": "node get-order-detail.js",
|
||||||
"verify-backtest-data": "node verify-backtest-data.js",
|
"verify-backtest-data": "node verify-backtest-data.js",
|
||||||
"ws-binance-klines": "node ws_binance_btc_usdc_klines.js"
|
"ws-binance-klines": "node ws_binance_btc_usdc_klines.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ethersproject/wallet": "^5.7.0",
|
"@ethersproject/wallet": "^5.7.0",
|
||||||
|
"ethers": "^5.7.1",
|
||||||
"@polymarket/clob-client": "^5.2.1",
|
"@polymarket/clob-client": "^5.2.1",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Wrapped Collateral (WCOL) 解包为 USDC.e
|
||||||
|
*
|
||||||
|
* Polygon 上 Polymarket 使用 USDC.e(Bridged USDC),解包后到账的为 USDC.e,在钱包/区块浏览器中显示为 USDC.e。
|
||||||
|
*
|
||||||
|
* 合约: Polymarket Neg Risk WrappedCollateral
|
||||||
|
* Polygon: 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2
|
||||||
|
* 方法: unwrap(address _to, uint256 _amount)
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* 1) 从 Safe 解包(Gasless,需 Builder 凭证,主钱包无需 POL):
|
||||||
|
* PRIVATE_KEY=0x... SAFE_ADDRESS=0xB4c3... BUILDERS_API_KEY=... BUILDERS_SECRET=... BUILDERS_PASSPHRASE=... node scripts/unwrap-wcol.js
|
||||||
|
* 2) 从 Safe 解包(自付 gas,主钱包需少量 POL):
|
||||||
|
* PRIVATE_KEY=0x... SAFE_ADDRESS=0xB4c3... node scripts/unwrap-wcol.js
|
||||||
|
* 3) 从 EOA 解包(WCOL 在主钱包上):
|
||||||
|
* PRIVATE_KEY=0x... node scripts/unwrap-wcol.js
|
||||||
|
*
|
||||||
|
* 环境变量:
|
||||||
|
* PRIVATE_KEY 主钱包私钥(必需)
|
||||||
|
* SAFE_ADDRESS 若设置则从 Safe 执行 unwrap
|
||||||
|
* UNWRAP_TO_MAIN_WALLET=1 从 Safe 解包时,USDC.e 转到主钱包;不设则转到 Safe
|
||||||
|
* BUILDERS_API_KEY / BUILDERS_SECRET / BUILDERS_PASSPHRASE 三者齐备时走 Builder Relayer(Gasless,主钱包无需 POL)
|
||||||
|
* RPC_URL Polygon RPC(可选)
|
||||||
|
* RELAYER_URL Builder Relayer 地址(可选,默认 https://relayer-v2.polymarket.com)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ethers } from "ethers";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
const CHAIN_ID = 137;
|
||||||
|
const RPC_URL = process.env.RPC_URL || "https://polygon-rpc.com";
|
||||||
|
const RELAYER_URL = (process.env.RELAYER_URL || "https://relayer-v2.polymarket.com").replace(/\/$/, "");
|
||||||
|
const WCOL_ADDRESS = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2";
|
||||||
|
|
||||||
|
const WCOL_ABI = [
|
||||||
|
"function balanceOf(address account) view returns (uint256)",
|
||||||
|
"function decimals() view returns (uint8)",
|
||||||
|
"function unwrap(address _to, uint256 _amount)",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Safe 合约:nonce()
|
||||||
|
const SAFE_ABI = ["function nonce() view returns (uint256)"];
|
||||||
|
|
||||||
|
function normalizePrivateKey(key) {
|
||||||
|
if (!key || !key.trim()) return null;
|
||||||
|
const k = key.trim();
|
||||||
|
return k.startsWith("0x") ? k : "0x" + k;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrivateKey() {
|
||||||
|
const key = normalizePrivateKey(process.env.PRIVATE_KEY);
|
||||||
|
if (!key) {
|
||||||
|
console.error("请设置环境变量 PRIVATE_KEY(主钱包私钥)");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EIP-712: domain type hash for Safe (chainId + verifyingContract)
|
||||||
|
function getSafeDomainTypeHash() {
|
||||||
|
const typeStr = "EIP712Domain(uint256 chainId,address verifyingContract)";
|
||||||
|
return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(typeStr));
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeSafeDomain(chainId, verifyingContract) {
|
||||||
|
const typeHash = getSafeDomainTypeHash();
|
||||||
|
const chainIdHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(chainId).toHexString(), 32);
|
||||||
|
const contractHex = ethers.utils.hexZeroPad(verifyingContract.toLowerCase(), 32);
|
||||||
|
return ethers.utils.keccak256(ethers.utils.concat([typeHash, chainIdHex, contractHex]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeTx type hash
|
||||||
|
function getSafeTxTypeHash() {
|
||||||
|
const typeStr =
|
||||||
|
"SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)";
|
||||||
|
return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(typeStr));
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeSafeTxMessage(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce) {
|
||||||
|
const typeHash = getSafeTxTypeHash();
|
||||||
|
const toHex = ethers.utils.hexZeroPad(to.toLowerCase(), 32);
|
||||||
|
const valueHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(value).toHexString(), 32);
|
||||||
|
const dataHash = data && data !== "0x" ? ethers.utils.keccak256(data) : ethers.constants.HashZero;
|
||||||
|
const opHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(operation).toHexString(), 32);
|
||||||
|
const safeTxGasHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(safeTxGas).toHexString(), 32);
|
||||||
|
const baseGasHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(baseGas).toHexString(), 32);
|
||||||
|
const gasPriceHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(gasPrice).toHexString(), 32);
|
||||||
|
const gasTokenHex = ethers.utils.hexZeroPad(gasToken.toLowerCase(), 32);
|
||||||
|
const refundHex = ethers.utils.hexZeroPad(refundReceiver.toLowerCase(), 32);
|
||||||
|
const nonceHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(nonce).toHexString(), 32);
|
||||||
|
return ethers.utils.keccak256(
|
||||||
|
ethers.utils.concat([
|
||||||
|
typeHash,
|
||||||
|
toHex,
|
||||||
|
valueHex,
|
||||||
|
dataHash,
|
||||||
|
opHex,
|
||||||
|
safeTxGasHex,
|
||||||
|
baseGasHex,
|
||||||
|
gasPriceHex,
|
||||||
|
gasTokenHex,
|
||||||
|
refundHex,
|
||||||
|
nonceHex,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashStructuredData(domainSeparator, messageHash) {
|
||||||
|
const prefix = "0x1901";
|
||||||
|
return ethers.utils.keccak256(ethers.utils.concat([prefix, domainSeparator, messageHash]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gnosis Safe 签名:先对 structHash 做 personal_sign 风格(\x19Ethereum Signed Message:\n32 + hash),再对结果做 keccak256 后签名
|
||||||
|
async function signSafeTx(wallet, structHash) {
|
||||||
|
const hashBytes = ethers.utils.arrayify(structHash);
|
||||||
|
const messagePrefix = "\x19Ethereum Signed Message:\n32";
|
||||||
|
const prefixed = ethers.utils.concat([
|
||||||
|
ethers.utils.toUtf8Bytes(messagePrefix),
|
||||||
|
hashBytes,
|
||||||
|
]);
|
||||||
|
const hashToSign = ethers.utils.keccak256(prefixed);
|
||||||
|
const sig = await wallet._signingKey().signDigest(ethers.utils.arrayify(hashToSign));
|
||||||
|
let v = sig.v;
|
||||||
|
if (v < 27) v += 27;
|
||||||
|
const r = ethers.utils.hexZeroPad(ethers.BigNumber.from(sig.r).toHexString(), 32).slice(2);
|
||||||
|
const s = ethers.utils.hexZeroPad(ethers.BigNumber.from(sig.s).toHexString(), 32).slice(2);
|
||||||
|
const vByte = v.toString(16).padStart(2, "0");
|
||||||
|
return "0x" + r + s + vByte;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builder Relayer 要求 Safe 签名使用调整后的 v(与后端 splitAndPackSig 一致),否则链上恢复签名者会失败
|
||||||
|
function packSafeSignatureForRelayer(signatureHex) {
|
||||||
|
const raw = signatureHex.startsWith("0x") ? signatureHex.slice(2) : signatureHex;
|
||||||
|
if (raw.length !== 130) throw new Error("签名长度应为 65 字节 (130 个十六进制字符)");
|
||||||
|
const r = raw.slice(0, 64);
|
||||||
|
const s = raw.slice(64, 128);
|
||||||
|
const vHex = raw.slice(128, 130);
|
||||||
|
let v = parseInt(vHex, 16);
|
||||||
|
const adjustedV = v === 0 || v === 1 ? v + 31 : v === 27 || v === 28 ? v + 4 : v;
|
||||||
|
return "0x" + r + s + adjustedV.toString(16).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 execTransaction(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, signatures)
|
||||||
|
function buildExecTransactionCalldata(to, data, operation, signatureHex) {
|
||||||
|
const iface = new ethers.utils.Interface([
|
||||||
|
"function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)",
|
||||||
|
]);
|
||||||
|
const value = 0;
|
||||||
|
const safeTxGas = 0;
|
||||||
|
const baseGas = 0;
|
||||||
|
const gasPrice = 0;
|
||||||
|
const gasToken = ethers.constants.AddressZero;
|
||||||
|
const refundReceiver = ethers.constants.AddressZero;
|
||||||
|
const sigBytes = ethers.utils.arrayify(signatureHex);
|
||||||
|
return iface.encodeFunctionData("execTransaction", [
|
||||||
|
to,
|
||||||
|
value,
|
||||||
|
data,
|
||||||
|
operation,
|
||||||
|
safeTxGas,
|
||||||
|
baseGas,
|
||||||
|
gasPrice,
|
||||||
|
gasToken,
|
||||||
|
refundReceiver,
|
||||||
|
sigBytes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBuildersConfigured() {
|
||||||
|
const k = process.env.BUILDERS_API_KEY?.trim();
|
||||||
|
const s = process.env.BUILDERS_SECRET?.trim();
|
||||||
|
const p = process.env.BUILDERS_PASSPHRASE?.trim();
|
||||||
|
return !!(k && s && p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBuilderSignature(signString, secret) {
|
||||||
|
let decodedSecret;
|
||||||
|
try {
|
||||||
|
decodedSecret = Buffer.from(secret, "base64");
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
decodedSecret = Buffer.from(secret.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
||||||
|
} catch {
|
||||||
|
decodedSecret = Buffer.from(secret, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hmac = crypto.createHmac("sha256", decodedSecret);
|
||||||
|
hmac.update(signString, "utf8");
|
||||||
|
const base64 = hmac.digest("base64");
|
||||||
|
return base64.replace(/\+/g, "-").replace(/\//g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBuilderHeaders(method, path, body, apiKey, secret, passphrase) {
|
||||||
|
const timestamp = Date.now().toString();
|
||||||
|
const bodyStr = body ?? "";
|
||||||
|
const signString = timestamp + method + path + bodyStr;
|
||||||
|
const signature = buildBuilderSignature(signString, secret);
|
||||||
|
return {
|
||||||
|
POLY_BUILDER_SIGNATURE: signature,
|
||||||
|
POLY_BUILDER_TIMESTAMP: timestamp,
|
||||||
|
POLY_BUILDER_API_KEY: apiKey,
|
||||||
|
POLY_BUILDER_PASSPHRASE: passphrase,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relayerGetNonce(fromAddress) {
|
||||||
|
const pathForSign = "/nonce";
|
||||||
|
const url = RELAYER_URL + "/nonce?address=" + encodeURIComponent(fromAddress) + "&type=SAFE";
|
||||||
|
const headers = getBuilderHeaders(
|
||||||
|
"GET",
|
||||||
|
pathForSign,
|
||||||
|
"",
|
||||||
|
process.env.BUILDERS_API_KEY.trim(),
|
||||||
|
process.env.BUILDERS_SECRET.trim(),
|
||||||
|
process.env.BUILDERS_PASSPHRASE.trim()
|
||||||
|
);
|
||||||
|
const res = await fetch(url, { method: "GET", headers });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error("Relayer getNonce 失败: " + res.status + " " + text);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relayerSubmit(requestBody) {
|
||||||
|
const pathForSign = "/submit";
|
||||||
|
const body = JSON.stringify(requestBody);
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getBuilderHeaders(
|
||||||
|
"POST",
|
||||||
|
pathForSign,
|
||||||
|
body,
|
||||||
|
process.env.BUILDERS_API_KEY.trim(),
|
||||||
|
process.env.BUILDERS_SECRET.trim(),
|
||||||
|
process.env.BUILDERS_PASSPHRASE.trim()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const res = await fetch(RELAYER_URL + pathForSign, { method: "POST", headers, body });
|
||||||
|
const rawText = await res.text();
|
||||||
|
if (!res.ok) throw new Error("Relayer submit 失败: " + res.status + " " + rawText);
|
||||||
|
const data = JSON.parse(rawText);
|
||||||
|
console.log("Relayer submit 原始响应:", JSON.stringify(data, null, 2));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relayerGetTransaction(transactionId) {
|
||||||
|
const pathForSign = "/transaction";
|
||||||
|
const url = RELAYER_URL + "/transaction?id=" + encodeURIComponent(transactionId);
|
||||||
|
const headers = getBuilderHeaders(
|
||||||
|
"GET",
|
||||||
|
pathForSign,
|
||||||
|
"",
|
||||||
|
process.env.BUILDERS_API_KEY.trim(),
|
||||||
|
process.env.BUILDERS_SECRET.trim(),
|
||||||
|
process.env.BUILDERS_PASSPHRASE.trim()
|
||||||
|
);
|
||||||
|
const res = await fetch(url, { method: "GET", headers });
|
||||||
|
const rawText = await res.text();
|
||||||
|
if (!res.ok) throw new Error("Relayer getTransaction 失败: " + res.status + " " + rawText);
|
||||||
|
const data = JSON.parse(rawText);
|
||||||
|
return { parsed: Array.isArray(data) ? data[0] : data, raw: data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSuccessState(state) {
|
||||||
|
if (!state) return false;
|
||||||
|
const s = String(state).toUpperCase();
|
||||||
|
return s === "STATE_CONFIRMED" || s === "STATE_MINED" || s === "STATE_EXECUTED";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFailedState(state) {
|
||||||
|
if (!state) return false;
|
||||||
|
const s = String(state).toUpperCase();
|
||||||
|
return s === "STATE_FAILED" || s === "STATE_INVALID";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 链上交易 hash 应为 0x + 64 个十六进制字符
|
||||||
|
function isChainTxHash(s) {
|
||||||
|
if (!s || typeof s !== "string") return false;
|
||||||
|
const t = s.trim();
|
||||||
|
return /^0x[0-9a-fA-F]{64}$/.test(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChainTxHashFromTx(tx) {
|
||||||
|
if (!tx) return null;
|
||||||
|
const candidates = [
|
||||||
|
tx.transactionHash,
|
||||||
|
tx.hash,
|
||||||
|
tx.txHash,
|
||||||
|
tx.blockchainHash,
|
||||||
|
tx.chainTxHash,
|
||||||
|
tx.executionHash,
|
||||||
|
];
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (isChainTxHash(c)) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollUntilMined(transactionId, maxAttempts = 30, intervalMs = 3000) {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
const { parsed: tx, raw: rawData } = await relayerGetTransaction(transactionId);
|
||||||
|
const state = tx?.state;
|
||||||
|
if (isSuccessState(state)) {
|
||||||
|
console.log("Relayer getTransaction 原始响应 (最终状态):", JSON.stringify(rawData, null, 2));
|
||||||
|
const chainHash = getChainTxHashFromTx(tx);
|
||||||
|
if (chainHash) return chainHash;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (isFailedState(state)) {
|
||||||
|
console.log("Relayer getTransaction 原始响应 (失败状态):", JSON.stringify(rawData, null, 2));
|
||||||
|
const detail = tx.transactionHash ? ` txHash=${tx.transactionHash}` : "";
|
||||||
|
const errMsg = tx.errorMessage ?? tx.error ?? tx.revertReason ?? "";
|
||||||
|
const full = errMsg ? ` ${errMsg}` : detail;
|
||||||
|
throw new Error("Relayer 交易失败: state=" + state + full);
|
||||||
|
}
|
||||||
|
if (i === 0) console.log("等待 Relayer 确认交易,每", intervalMs / 1000, "秒查询一次...");
|
||||||
|
console.log(` [${i + 1}/${maxAttempts}] state=${state ?? "未知"}`);
|
||||||
|
await new Promise((r) => setTimeout(r, intervalMs));
|
||||||
|
}
|
||||||
|
throw new Error("等待交易确认超时");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const privateKey = getPrivateKey();
|
||||||
|
const safeAddress = process.env.SAFE_ADDRESS?.trim();
|
||||||
|
|
||||||
|
const provider = new ethers.providers.JsonRpcProvider(RPC_URL);
|
||||||
|
const wallet = new ethers.Wallet(privateKey, provider);
|
||||||
|
const wcol = new ethers.Contract(WCOL_ADDRESS, WCOL_ABI, provider);
|
||||||
|
|
||||||
|
const ownerAddress = wallet.address;
|
||||||
|
const targetBalanceAddress = safeAddress || ownerAddress;
|
||||||
|
const balanceRaw = await wcol.balanceOf(targetBalanceAddress);
|
||||||
|
const decimals = await wcol.decimals();
|
||||||
|
const balanceFormatted = ethers.utils.formatUnits(balanceRaw, decimals);
|
||||||
|
|
||||||
|
if (balanceRaw.isZero()) {
|
||||||
|
console.log(`地址 ${targetBalanceAddress} 的 WCOL 余额为 0,无需解包。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`WCOL 余额: ${balanceFormatted} (${targetBalanceAddress})`);
|
||||||
|
|
||||||
|
if (safeAddress) {
|
||||||
|
const iface = new ethers.utils.Interface(WCOL_ABI);
|
||||||
|
// 从 Safe 解包时,USDC 默认转到 Safe;设环境变量 UNWRAP_TO_MAIN_WALLET=1 则转到主钱包
|
||||||
|
const unwrapTo = process.env.UNWRAP_TO_MAIN_WALLET === "1" ? ownerAddress : safeAddress;
|
||||||
|
const unwrapData = iface.encodeFunctionData("unwrap", [unwrapTo, balanceRaw]);
|
||||||
|
|
||||||
|
const safeContract = new ethers.Contract(safeAddress, SAFE_ABI, provider);
|
||||||
|
|
||||||
|
const useRelayer = isBuildersConfigured();
|
||||||
|
let nonce;
|
||||||
|
if (useRelayer) {
|
||||||
|
const relayerNonce = await relayerGetNonce(safeAddress);
|
||||||
|
nonce = await safeContract.nonce();
|
||||||
|
console.log("Nonce: Relayer 返回=" + relayerNonce.toString() + ", Safe 链上=" + nonce.toString() + "(Gasless 一律用链上 nonce 签名)");
|
||||||
|
} else {
|
||||||
|
nonce = await safeContract.nonce();
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainSeparator = encodeSafeDomain(CHAIN_ID, safeAddress);
|
||||||
|
const zero = "0";
|
||||||
|
const gasToken = ethers.constants.AddressZero;
|
||||||
|
const messageHash = encodeSafeTxMessage(
|
||||||
|
WCOL_ADDRESS,
|
||||||
|
zero,
|
||||||
|
unwrapData,
|
||||||
|
0,
|
||||||
|
zero,
|
||||||
|
zero,
|
||||||
|
zero,
|
||||||
|
gasToken,
|
||||||
|
gasToken,
|
||||||
|
nonce
|
||||||
|
);
|
||||||
|
const structHash = hashStructuredData(domainSeparator, messageHash);
|
||||||
|
const signatureHex = await signSafeTx(wallet, structHash);
|
||||||
|
|
||||||
|
if (useRelayer) {
|
||||||
|
console.log("使用 Builder Relayer(Gasless),主钱包无需 POL");
|
||||||
|
console.log("若链上仍报 GS026,多为 Relayer 上链前 Safe 又执行了其他交易导致 nonce 变化,可改用自付 gas(不设 BUILDERS_*)或确保此时无其他 Safe 交易。");
|
||||||
|
const relayerSignature = packSafeSignatureForRelayer(signatureHex);
|
||||||
|
const requestBody = {
|
||||||
|
type: "SAFE",
|
||||||
|
from: ownerAddress,
|
||||||
|
to: WCOL_ADDRESS,
|
||||||
|
proxyWallet: safeAddress,
|
||||||
|
data: unwrapData,
|
||||||
|
nonce: nonce.toString(),
|
||||||
|
signature: relayerSignature,
|
||||||
|
signatureParams: {
|
||||||
|
gasPrice: "0",
|
||||||
|
operation: "0",
|
||||||
|
safeTxnGas: "0",
|
||||||
|
baseGas: "0",
|
||||||
|
gasToken: ethers.constants.AddressZero,
|
||||||
|
refundReceiver: ethers.constants.AddressZero,
|
||||||
|
},
|
||||||
|
metadata: "unwrap WCOL to USDC.e",
|
||||||
|
};
|
||||||
|
const result = await relayerSubmit(requestBody);
|
||||||
|
const txId = result.transactionID ?? result.transactionId ?? result.id ?? result.transaction_id;
|
||||||
|
if (!txId) {
|
||||||
|
console.log("Relayer 已提交,响应:", JSON.stringify(result, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("Relayer 已提交,transactionId:", txId);
|
||||||
|
const txHash = await pollUntilMined(txId);
|
||||||
|
if (txHash) {
|
||||||
|
console.log("交易已确认 (Polygon):", txHash);
|
||||||
|
} else {
|
||||||
|
console.log("Relayer 已标记为成功,但未返回链上 tx hash。可在 https://polygonscan.com 用 Safe 地址查看最新交易:", safeAddress);
|
||||||
|
console.log("若需排查,可设置 DEBUG=1 查看 Relayer 返回的完整数据");
|
||||||
|
}
|
||||||
|
console.log("已解包", balanceFormatted, "WCOL → USDC.e,到账地址:", unwrapTo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const execCalldata = buildExecTransactionCalldata(WCOL_ADDRESS, unwrapData, 0, signatureHex);
|
||||||
|
console.log("正在由 Safe 执行 unwrap,主钱包发送交易(需少量 POL 付 gas)...");
|
||||||
|
const tx = await wallet.sendTransaction({
|
||||||
|
to: safeAddress,
|
||||||
|
data: execCalldata,
|
||||||
|
value: 0,
|
||||||
|
gasLimit: 500000,
|
||||||
|
});
|
||||||
|
console.log("Tx hash:", tx.hash);
|
||||||
|
const receipt = await tx.wait();
|
||||||
|
console.log("已确认,block:", receipt.blockNumber);
|
||||||
|
console.log("已解包", balanceFormatted, "WCOL → USDC.e,到账地址:", unwrapTo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("正在发送 unwrap 交易,USDC.e 将转到:", ownerAddress);
|
||||||
|
const tx = await wcol.connect(wallet).unwrap(ownerAddress, balanceRaw);
|
||||||
|
console.log("Tx hash:", tx.hash);
|
||||||
|
const receipt = await tx.wait();
|
||||||
|
console.log("已确认,block:", receipt.blockNumber);
|
||||||
|
console.log("已解包", balanceFormatted, "WCOL → USDC.e");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user