Compare commits

..

38 Commits

Author SHA1 Message Date
WrBug f1ec0a330b feat(account): 按代理地址去重、默认账户名、前端重复提示与 DB 唯一约束
后端:
- 导入去重改为按 proxy_address(existsByProxyAddress),重复时返回 ACCOUNT_ALREADY_EXISTS(4601)
- 未填账户名时默认生成 SAFE/MAGIC-代理地址后4位(无中括号)
- Controller 识别 ACCOUNT_ALREADY_EXISTS 并返回对应错误码

前端:
- 导入失败时 message.error 提示;code=4601 时使用 accountImport.duplicateAccount 多语言
- store 抛出错误时附带 response.data.code 供表单判断

数据库 V33:
- 移除 wallet_address 唯一约束,新增 proxy_address 唯一约束
- 已存在账户的 wallet_type 统一更新为 safe

实体:
- Account.walletAddress 取消 unique,Account.proxyAddress 设为 unique

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 01:40:25 +08:00
WrBug 72de65d670 feat(frontend): 优化导入账户弹窗与文案
- 导入弹窗:增加步骤条、导入方式改为按钮、代理选项卡片紧凑展示
- 代理选项:标题改为「请选择账户类型」,地址完整显示,有资产标绿,小白说明放入卡片内
- 私钥/助记词:输入框默认两行、禁止换行(换行自动转空格/去除)
- 多语言:新增 proxyAddressHelp,更新 selectProxyOption 文案
- Modal 宽度与内边距微调;PositionList 移除未使用 useRef

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 01:23:29 +08:00
WrBug 3405a1cda3 feat: 钱包类型枚举与前端账号类型展示
后端:
- 新增 WalletType 枚举(MAGIC/SAFE),移除 safe/magic 字符串硬编码
- RelayClientService/BlockchainService/AccountService/OrderSigningService 使用枚举
- Builder Relayer API 类型使用常量 RELAYER_TYPE_PROXY/SAFE

前端:
- 账户列表、详情、导入 Modal 显示账号类型(Magic/Safe Tag)
- 导入账户 Modal 移除安全提示 Alert,移除 showAlert 与相关 i18n
- 移除无用 i18n key:securityTip、securityTipDesc、walletTypeMagic、walletTypeSafe、magicNotSupported
- 钱包类型 Tag 简化为仅显示 Magic 或 Safe

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 01:14:04 +08:00
WrBug fc6fa8b419 fix: 修复 Magic 账户赎回失败问题
主要修复:
1. 修复 PROXY ABI 编码问题:
   - 添加缺失的 tuple offset 字段(32字节)
   - 修正 data offset 从 192 改为 128(符合 ABI 规范)
   - 与 builder-relayer-client 保持一致

2. 添加 gas limit 动态估算:
   - 通过 eth_estimateGas 估算合理的 gas limit
   - 避免硬编码 10M 导致 Relay Hub gasleft() 检查失败
   - 估算失败时回退到默认值

3. 优化 API Key 检查逻辑:
   - 仅在涉及 Magic 账户时提前检查 Builder API Key
   - Safe 账户可在未配置 API Key 时使用手动交易

修复的问题:
- Magic 赎回交易失败:Not enough gasleft()
- PROXY 编码格式与官方不一致

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 00:59:39 +08:00
WrBug cf2c8a611c feat: 赎回批量执行与仓位页体验优化
- 赎回:同一账户多市场合并为一笔交易,减少 Relayer 调用次数
- 仓位页:可赎回统计静默刷新,赎回按钮不再常驻 loading
- 账户导入表单、API、多语言等相关改动

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 00:20:41 +08:00
WrBug bd323fca35 feat: 支持 Magic(邮箱/社交登录)账户导入和赎回
- 前端:启用 Magic 账户导入选项(移除 disabled 和"暂不支持"提示)
- 订单签名:根据钱包类型自动设置 signatureType(Magic=1, Safe=2)
- Builder Relayer API:添加 relay-payload 接口和 PROXY 请求类型支持
- RelayClientService:实现 Magic PROXY 赎回流程(Gasless,需 Builder API Key)
  - 获取 relay payload(relay 地址和 nonce)
  - 编码 ProxyFactory.proxy(calls) 调用数据
  - 创建并签名 PROXY 结构哈希
  - 提交 PROXY 类型交易到 Builder Relayer
- BlockchainService:redeemPositions 支持 walletType 参数
- AccountService:移除 Magic 账户赎回限制,传递 walletType 参数

Magic 账户赎回要求配置 Builder API Key(Gasless),Safe 账户保持原有逻辑不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 22:51:18 +08:00
WrBug a067a20a02 feat: 回测任务支持价格区间过滤
- 后端:
  - BacktestTask 实体类添加 minPrice 和 maxPrice 字段
  - BacktestDto 添加价格区间参数和配置字段
  - BacktestService 支持创建和查询价格区间配置
  - BacktestExecutionService 在 taskToCopyTrading 中设置价格区间
  - 添加数据库迁移脚本 V32__add_backtest_price_range_filter.sql

- 前端:
  - BacktestList 添加价格区间输入表单(最低价、最高价)
  - 详情页面显示价格区间配置
  - 一键创建跟单配置时包含价格区间
  - 添加中英繁三语言翻译
  - 修复价格输入框精度显示问题(0.4 不再显示为 0.40000000)

功能说明:
- 价格区间过滤与跟单配置保持一致,确保回测结果能准确反映实际跟单效果
- 支持只设置最低价、只设置最高价、或同时设置两者
- 留空表示不限制价格

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-11 23:54:49 +08:00
WrBug 99c4328830 feat(backtest): 失败与已停止的回测任务支持在列表中删除
操作列删除按钮对 STOPPED、FAILED 状态也显示,与详情页一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:57:37 +08:00
WrBug 1df438d18d fix(frontend): 修复 BacktestList 与 AddModal 的 TypeScript 类型错误
- BacktestList: Option 改为 Select.Option,避免与原生 HTML Option 冲突
- AddModal: onSelectChange 仅在 value 有值时调用 fetchLeaderAssetInfo

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:46:27 +08:00
WrBug 366d3d1b25 fix(positions): 修复仓位管理点击市场链接打开两个标签页的问题
在市场列链接的 onClick 中增加 e.preventDefault(),阻止 <a> 默认行为,
避免与 window.open() 重复打开同一页面。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:41:59 +08:00
WrBug 17a6a4f314 fix: 创建跟单时带入最大仓位金额参数
修复从回测任务创建跟单时,maxPositionValue 参数没有带入的问题。

修改内容:
- BacktestList.tsx:两处创建跟单配置时添加 maxPositionValue 字段
  1) handleCreateCopyTrading 函数(预填充配置)
  2) 复制按钮的预填充配置
- CopyTradingOrders/AddModal.tsx:
  1) AddModalProps 接口添加 maxPositionValue 字段
  2) fillPreFilledConfig 函数添加 maxPositionValue 处理

类型定义已存在:
- types/index.ts 的 CopyTradingCreateRequest 已包含 maxPositionValue 字段
- CopyTradingOrders/AddModal.tsx 的 handleSubmit 已处理该字段(第 355 行)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:40:12 +08:00
WrBug 8600449f69 fix(copytrading): 赎回后按 copyTrading 分别生成自动卖出记录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:38:47 +08:00
WrBug 3d47bd50d9 chore(backtest): 移除回测轮询服务的冗余日志
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:31:26 +08:00
WrBug 20df471683 feat: 回测任务添加最大仓位参数支持
参考跟单配置的实现,为回测任务添加最大仓位金额限制功能。

后端改动:
- 添加数据库迁移文件 V31__add_backtest_max_position_value.sql
- BacktestTask 实体添加 maxPositionValue 字段(BigDecimal?,NULL表示不启用)
- BacktestCreateRequest 添加 maxPositionValue 字段
- BacktestConfigDto 添加 maxPositionValue 字段
- BacktestService 处理 maxPositionValue 的创建、详情和复制
- BacktestExecutionService.taskToCopyTrading 映射 maxPositionValue 参数
- BacktestExecutionService.executeBacktest 在买入逻辑中添加仓位检查
  * 检查条件:当前仓位 + 买入金额 <= 最大仓位
  * 仓位价值计算:quantity * avgPrice
  * 超过限制时跳过该笔买入并记录详细日志
  * 按市场+方向(marketId + outcomeIndex)分别检查

前端改动:
- BacktestList 表单添加最大仓位金额输入框(可选字段)
- 创建回测任务时包含 maxPositionValue
- 回测任务详情中显示 maxPositionValue(仅在配置了时显示)
- 默认最大每日订单数从 50 改为 100
- 多语言翻译新增:
  * backtest.maxPositionValue: 最大仓位金额 / 最大倉位金額 / Max Position Value
  * maxPositionValuePlaceholder: 留空表示不启用最大仓位限制

功能特点:
- 可选参数:留空或为 null 时表示不启用该限制,保持向后兼容
- 单市场单方向限制:按 marketId 和 outcomeIndex 分别计算和限制
- 精确计算:使用 BigDecimal 进行数值比较
- 详细日志:超过限制时记录当前仓位、买入金额、总计等详细信息

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:25:24 +08:00
WrBug 5487c7c862 feat(leader): Leader 管理展示跟单/回测数量并支持跳转筛选
- 后端:LeaderDto 增加 backtestCount,LeaderService 统计回测数并返回
- Leader 列表:展示跟单数、回测数,点击跳转跟单配置/回测页并带 leaderId 筛选
- 跟单配置页:从 URL 读取 leaderId 并应用筛选,Leader 下拉改用 LeaderSelect
- 回测页:支持 Leader 筛选,页面加载时请求 Leader 列表,筛选与详情统一显示规则
- LeaderSelect:支持 style,回测/跟单筛选统一使用
- 多语言:补充 leaderList 相关 key
- 移动端:Leader 管理卡片内按钮增加内边距 padding 8px 16px

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:03:17 +08:00
WrBug c1829fe21b feat(backtest): 结算显示市场名称,列表非终态任务 3s 轮询进度
- 结算记录: WIN/LOSE/UNKNOWN/CLOSED 通过 marketService.getMarket 写入 marketTitle
- 回测列表: 存在 PENDING/RUNNING 时每 3 秒静默刷新列表更新进度,全部终态后停止轮询

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 01:12:31 +08:00
WrBug b1ad6f02d2 feat(backtest): 回测结算持久化、按配置重新测试、执行时以当前时间为窗口基准
- 持久化: BUY/SELL 与 SETTLEMENT(WIN/LOSE/UNKNOWN/CLOSED) 均写入 backtest_trade
- 重新测试: 已完成任务支持「按当前配置重新测试」,新任务名称可编辑,后端 POST /tasks/rerun + 前端按钮与确认弹窗
- 回测窗口: 首次执行以当前时间为终点、startTime = endTime - backtestDays(局部变量,不修改实体)
- 新增错误码 BACKTEST_TASK_NOT_COMPLETED、SERVER_BACKTEST_RERUN_FAILED 及多语言

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 01:08:40 +08:00
WrBug 01a1836659 docs: 清理根目录临时文档
- 删除过时的版本发布说明(RELEASE.md 和 RELEASE_NOTES_v2.0.*.md)
- 删除临时检查报告(CHECK_AND_FIX_REPORT.md、IMPLEMENTATION_COMPLETE.md、MISSING_ITEMS_CHECK.md)
- 所有版本发布信息统一在 RELEASE_NOTES.md 中管理
- 添加新的部署指南文档和 Docker 构建配置

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 23:34:46 +08:00
WrBug b616e10ceb feat(skills): 添加 check-i18n-keys 技能并补全多语言 key
- 按 Cursor Agent Skills 规范:技能目录含 SKILL.md 与 scripts/
- 多语言检查脚本:扫描 t() 引用,对比 zh-CN/zh-TW/en common.json
- 补全缺失 key:common.create/copy/copySuccess/copyFailed、
  leaderList.openDetailFailed、leaderDetail.market、
  filteredOrdersList.orderDetails、filteredOrdersList.filterTypes.keywordFilter

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 23:17:27 +08:00
WrBug d07c4c2c5e fix: improve deployment info display
- Add admin reset password key to deployment completion info
- Fix color code display by adding -e flag to echo commands
- Display admin reset key in plaintext for easy reference
2026-02-01 03:17:58 +08:00
WrBug 7485e2ce24 feat: add interactive deployment script with one-liner installation
- Add deploy-interactive.sh: Interactive deployment script with automatic configuration
  - Auto-generate secure random keys (JWT, admin reset key, crypto key, DB password)
  - Smart .env detection for quick updates without reconfiguration
  - Random port generation (10000-60000) for SERVER_PORT default
  - Interactive prompts with clear defaults and auto-generation hints
  - Automatic docker-compose.prod.yml download from GitHub
  - Health check and deployment verification

- Add deploy-interactive-README.md: Comprehensive deployment guide
  - One-liner installation commands (curl/wget)
  - Quick update workflow for existing deployments
  - Detailed feature explanations and usage examples

- Update README.md & README_EN.md: Add one-liner installation section
  - Automatic working directory creation (~polyhermes)
  - Simplified deployment process for new users
  - Clear benefits and features list

Features:
- 📁 Auto-creates ~/polyhermes working directory
- 🔐 Generates secure random secrets automatically
-  Quick update mode: skip config if .env exists
- 🚀 One command from zero to running
- 🌐 Downloads latest docker-compose config from GitHub
-  Full health check and verification
2026-02-01 03:12:27 +08:00
WrBug 8f52b5016a feat: 将回测任务详情改为 Modal 方式展示
- 将任务详情从页面跳转改为 Modal 弹窗展示
- 在详情 Modal 中添加完整的统计信息、配置信息、图表和交易记录
- 修复统计数据显示:添加 totalTrades 字段,修复空值处理
- 将'创建跟单'按钮固定在 Modal 底部 footer
- 修复 Modal 滚动问题,确保内容可以正常滚动
- 修复编译错误:删除未使用的 navigate 导入
- 添加调试日志便于问题排查
2026-01-31 22:57:07 +08:00
WrBug 5c0808c2cb feat: 添加从回测任务创建跟单配置功能
- 在回测任务列表页和详情页添加'创建跟单'按钮
- 实现从回测任务配置自动填充到跟单配置表单
- 复用 AddModal 组件,支持 preFilledConfig 参数预填充
- 修复配置字段从 BacktestConfigDto 获取的问题
- 列表页点击按钮时先获取任务详情以获取配置信息
- 添加调试日志便于问题排查
- 更新多语言文案:'一键创建跟单配置' -> '创建跟单'
2026-01-31 22:23:44 +08:00
WrBug 9d01c120e5 fix: 修复回测任务恢复逻辑和分页问题
- 修复新建任务被误判为恢复任务的问题:将 lastProcessedTradeIndex 默认值从 0 改为 null
- 修复页码计算错误:统一页码从 0 开始,确保新任务的 offset 为 0
- 修复恢复任务时跳过已处理条目的逻辑
- 添加数据库迁移文件 V30,将现有新任务的索引值改为 NULL
2026-01-31 21:45:02 +08:00
WrBug ec8cfeac77 fix: 修复回测分页起始位置错误
- 修复新回测任务从第1页开始,导致 offset=100
- 应该从第0页开始,offset=0
- 修复后不会跳过前100条交易数据

Bug原因:
- BacktestExecutionService startPage 初始化为 1
- offset = page * size = 1 * 100 = 100
- 导致跳过前100条交易
2026-01-31 21:25:56 +08:00
WrBug 7c833f1e9b fix: 资金变化图始终显示所有交易数据
- 添加 allTrades 状态存储所有交易数据
- 添加 fetchAllTrades 方法获取所有数据(size=10000)
- 图表使用 allTrades 而不是分页的 trades
- 修复图表因分页只显示部分数据的问题
2026-01-31 21:15:09 +08:00
WrBug 08cc30a90f feat: 移除回测交易记录的盈亏列
- 移除盈亏列,进一步简化交易记录表格
- 盈亏信息已在统计卡片和图表中展示
2026-01-31 21:13:39 +08:00
WrBug 8288cd7579 feat: 移除回测交易记录的手续费列
- 移除手续费列,简化交易记录表格
- 手续费已包含在盈亏计算中,无需单独显示
2026-01-31 21:12:49 +08:00
WrBug 55da551971 fix: 修复回测统计计算空列表异常
- 修复 calculateStatistics 在 trades 为空时的 IndexOutOfBoundsException
- 添加空列表检查,避免访问 trades[0]
- 确保回测任务即使没有交易记录也能正常完成
2026-01-31 21:10:10 +08:00
WrBug ff3b24b50e fix: 优化回测交易记录显示
- 盈亏颜色逻辑:仅盈利(>0)显示绿色,亏损(<0)显示红色,持平(=0)无颜色
- 手续费显示:确保null值显示为 $0.00
- 遵守绿涨红跌原则
2026-01-31 20:58:13 +08:00
WrBug 88cae4018a fix: 修复 V29 migration SQL 语法兼容性
- 移除 DROP COLUMN IF EXISTS(仅 MySQL 8.0.29+ 支持)
- 使用标准 DROP COLUMN 语法(MySQL 5.7+ 兼容)
- 添加 Flyway 清理脚本
2026-01-31 20:51:26 +08:00
WrBug 96fbc3f720 feat: 回测系统优化 - 清理字段、移动端适配、缓存优化
## 主要改动

### 1. 回测字段清理
- 删除 BacktestCreate.tsx(未使用,创建使用 modal)
- 移除不适用于回测的字段:
  - priceTolerance, delaySeconds(回测使用历史数据)
  - minOrderDepth, maxSpread(无历史订单簿数据)
  - minPrice, maxPrice, maxPositionValue(回测中无实际意义)
  - maxMarketEndDate(意义不大)
- 更新相关 Entity、DTO、Service
- 添加数据库 migration(V29)

### 2. 移动端响应式适配
- BacktestList:筛选器、表格、Modal、表单响应式布局
- BacktestDetail:详情卡片、统计信息、按钮响应式
- 使用 useMediaQuery hook,断点 768px
- 支持手机、平板、桌面多种设备

### 3. 已结算市场缓存优化
- MarketPriceService 添加 Caffeine 缓存
- 缓存已结算市场价格,避免重复 RPC 调用
- 预计 RPC 请求减少 ~78.6%,性能提升显著
- 添加缓存统计和管理方法

### 4. UI 修复
- 修复 BacktestDetail 停止按钮文本
- 更新多语言翻译(supportSell → 跟单卖出)

## 性能收益
- 回测场景 RPC 调用减少 78.6%
- 回测执行时间预计减少 11秒(14秒 → 3秒)
- 移动端用户体验显著提升
2026-01-31 20:45:40 +08:00
WrBug fd25821e39 fix: 优化回测功能UI和修复问题
## 主要修改

### UI 优化
- 将回测功能移到跟单交易菜单下作为子菜单
- 创建回测改为使用 Modal 方式(移除独立页面)
- 优化导航路径,使用 useNavigate 替代 window.location.href

### 功能修复
- 修复 API 路径问题:移除重复的 /api 前缀(/api/backtest -> /backtest)
- 修复 copyRatio 输入框单位:改为百分比输入(100 表示 100%),与跟单配置保持一致
- 修复回测列表中 leader 列显示为空的问题:优先显示 leaderName,为空时显示 leaderAddress

### 多语言完善
- 补充缺失的多语言 key:
  - common.ascending / common.descending / common.day
  - backtest.sortBy / backtest.sortOrder / backtest.createdAt
  - backtest.copyRatioTooltip / backtest.copyRatioPlaceholder
  - 以及其他表单验证相关的 key
- 为中文简体、中文繁体、英文三种语言都添加了完整的翻译

## 修改的文件
- frontend/src/App.tsx - 移除 BacktestCreate 路由
- frontend/src/components/Layout.tsx - 调整菜单结构
- frontend/src/pages/BacktestList.tsx - Modal 创建、修复 leader 显示
- frontend/src/pages/BacktestDetail.tsx - 优化导航
- frontend/src/pages/BacktestCreate.tsx - 同步 copyRatio 修改
- frontend/src/services/api.ts - 修复 API 路径
- frontend/src/locales/* - 补充多语言配置
2026-01-31 07:46:16 +08:00
WrBug cdd02e9f3d feat: 实现回测功能
## 功能概述
实现完整的回测功能,支持基于历史数据模拟跟单策略的执行效果。

## 后端实现
- 数据库:新增 backtest_task 和 backtest_trade 表(V27迁移脚本)
- 实体类:BacktestTask、BacktestTrade
- Repository:BacktestTaskRepository、BacktestTradeRepository
- Service:
  - BacktestService:回测任务管理(CRUD)
  - BacktestDataService:从 Polymarket Data API 获取历史交易数据
  - BacktestExecutionService:回测算法核心实现
  - BacktestPollingService:定时轮询执行回测任务
- Controller:BacktestController(6个API接口)
- DTO:BacktestDto、TradeData
- 错误码:新增回测相关错误码和国际化消息

## 前端实现
- 页面组件:
  - BacktestList:回测任务列表
  - BacktestCreate:创建回测任务
  - BacktestDetail:回测详情(含图表)
  - BacktestChart:资金曲线图表(使用 ECharts)
- 类型定义:backtest.ts
- API 服务:集成所有回测接口
- 国际化:支持中英文

## 核心特性
- 回测天数限制:1-15 天
- 数据获取:直接从 Polymarket Data API 获取历史交易(不使用缓存表)
- 任务执行:同一时刻只执行一个任务,按创建时间顺序执行最早创建的任务
- 回测算法:完整实现市场结算、卖出匹配、价格容忍度、每日订单限制等规则
- 实时进度:支持任务进度更新和实时轮询

## 文档更新
- BACKTEST_PRD.md:产品需求文档
- BACKTEST_TECHNICAL_DESIGN.md:技术设计文档
- BACKTEST_REVIEW_CHECKLIST.md:设计评审检查清单

## 其他修改
- 移除 max_position_count 配置(V26迁移脚本)
- 移除 BacktestSyncService(不再需要实时同步)
- 修复前后端编译错误
2026-01-31 07:27:36 +08:00
WrBug fabbd81f22 Merge branch 'main' into backtest 2026-01-31 01:06:54 +08:00
WrBug ace32b37cf Merge pull request #26 from WrBug/remove-telegram-notify-workflow
chore: 移除 Telegram PR 合并通知工作流
2026-01-31 01:01:23 +08:00
WrBug f1f809f54b docs: 新增跟单回测功能设计文档
- 新增产品需求文档 (BACKTEST_PRD.md)
- 新增技术设计文档 (BACKTEST_TECHNICAL_DESIGN.md)
- 新增设计审查清单 (BACKTEST_REVIEW_CHECKLIST.md)
- 新增文档总览 (README.md)

关键设计:
- 实时市场结算机制(按endDate检查)
- 严格余额检查(不计入未实现持仓)
- 停止条件优化(余额不足且无持仓时停止)
- 不计算手续费(简化逻辑)
2026-01-31 00:26:00 +08:00
WrBug cccc829cef Merge pull request #25 from WrBug/dev
feat: 优化构建流程和通知机制
2026-01-29 04:09:44 +08:00
89 changed files with 11641 additions and 2365 deletions
+39
View File
@@ -0,0 +1,39 @@
# Cursor Skills 目录结构
本目录遵循 [Cursor Agent Skills 规范](https://cursor.com/cn/docs/context/skills):每个技能为一个**文件夹**,内含 `SKILL.md` 及可选的 `scripts/``references/``assets/`
## 目录结构
```
.cursor/skills/
├── frontend/ # 前端相关 skill 分组
│ └── check-i18n-keys/ # 单个 skill(文件夹名 = name
│ ├── SKILL.md # 必填,技能定义与指令
│ └── scripts/
│ ├── check-i18n-keys.ts
│ └── package.json
├── backend/ # 后端相关 skill
└── common/ # 通用 skill
```
- **SKILL.md**YAML frontmatter`name``description` 必填,`name` 须与父文件夹名一致、小写连字符)+ 给 Agent 的详细指令。
- **scripts/**Agent 可执行的脚本,在 SKILL.md 中用相对路径引用。
## 添加新 Skill
1. **确定分组**:在 `frontend/``backend/``common/` 下新建**以技能名命名的文件夹**(仅小写、数字、连字符,如 `check-i18n-keys`)。
2. **创建 SKILL.md**:在该文件夹内创建 `SKILL.md`(大写),frontmatter 中 `name` 必须与文件夹名一致。
3. **可选 scripts/**:在技能文件夹内建 `scripts/`,放入可执行脚本;在 SKILL.md 正文中写明运行命令(如 `cd .cursor/skills/.../scripts && npm run xxx`)。
## 示例
- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key。
## 运行 check-i18n-keys
```bash
cd .cursor/skills/frontend/check-i18n-keys/scripts
npm install
npm run check-i18n
```
@@ -0,0 +1,36 @@
---
name: check-i18n-keys
description: 检查前端多语言 key 完整性。当用户要求检查 i18n、多语言缺失、翻译 key 或运行 check-i18n 时使用。
---
# Check I18n Keys
检查前端代码中使用的 i18n key 是否在所有语言文件(zh-CN、zh-TW、en)中存在,并报告缺失或语言间不一致的 key。
## 使用时机
- 用户要求「检查多语言」「检查 i18n」「扫一下 key」「多语言缺失」时
- 用户要求运行多语言检查或执行 check-i18n 时
- 在修改或新增前端文案后,需要确认三语言 key 一致时
## 指令
1. **运行检查脚本**(在项目仓库根目录下执行):
```bash
cd .cursor/skills/frontend/check-i18n-keys/scripts && npm install && npm run check-i18n
```
首次运行需先 `npm install`,之后可直接 `npm run check-i18n`。
2. **脚本行为**
- 扫描 `frontend/` 下所有 `.ts`、`.tsx`、`.js`、`.jsx`(排除 node_modules、dist、build、*.d.ts
- 提取代码中 `t('key')` / `t("key")` 的 key
- 与 `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 对比
- 报告:代码中使用但某语言 JSON 缺失的 key;某语言有而另一语言没有的 key(不一致)
3. **结果处理**
- 若有缺失或不一致,退出码为 1;可根据报告在对应 `common.json` 中补全 key
- 脚本路径:`scripts/check-i18n-keys.ts`(相对本 skill 根目录)
## 可选目录说明
- `scripts/`:可执行检查脚本,Agent 按上述命令调用。
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
@@ -0,0 +1,302 @@
#!/usr/bin/env node
/**
* 检查前端多语言 key 完整性
*
* 扫描范围:frontend 下所有 .ts / .tsx / .js / .jsx(排除 node_modules、dist、build、*.d.ts
*
* 支持的检查:
* 1. 代码引用但 JSON 没有:某 ts/js 引用了 t("common.test"),但任意语言 common.json 里没有该 key → 报缺失
* 2. 语言间不一致:zh-CN/zh-TW 的 JSON 有 common.xxx,但 en 的没有 → 报不一致(某些语言有、某些没有)
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { glob } from 'glob';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
interface CheckResult {
usedKeys: Set<string>;
localeKeys: {
'zh-CN': Set<string>;
'zh-TW': Set<string>;
'en': Set<string>;
};
missingKeys: {
'zh-CN': string[];
'zh-TW': string[];
'en': string[];
};
inconsistentKeys: Array<{
key: string;
existsIn: string[];
missingIn: string[];
}>;
}
const LOCALES = ['zh-CN', 'zh-TW', 'en'] as const;
type Locale = typeof LOCALES[number];
// 颜色输出(如果支持)
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
function log(message: string, color: keyof typeof colors = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
/**
* 从代码文件中提取所有 t() 调用中的 key
*/
function extractKeysFromCode(frontendDir: string): Set<string> {
const keys = new Set<string>();
// 匹配 t('key') 或 t("key") 或 t(`key`)
const tPattern = /t\(['"`]([^'"`]+)['"`]\)/g;
// 扫描所有 TS/JS 文件(含 .ts .tsx .js .jsx),排除 node_modules、dist、build、*.d.ts
const files = glob.sync('**/*.{ts,tsx,js,jsx}', {
cwd: frontendDir,
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.d.ts'],
absolute: true,
});
for (const file of files) {
try {
const content = fs.readFileSync(file, 'utf-8');
let match;
while ((match = tPattern.exec(content)) !== null) {
const key = match[1];
// 过滤掉模板字符串中的变量(如 t(`key.${variable}`)
if (!key.includes('${') && !key.includes('${')) {
keys.add(key);
}
}
} catch (error) {
console.error(`Error reading file ${file}:`, error);
}
}
return keys;
}
/**
* 加载语言文件
*/
function loadLocaleFile(localeDir: string, locale: Locale): Record<string, any> {
const filePath = path.join(localeDir, locale, 'common.json');
if (!fs.existsSync(filePath)) {
log(`⚠️ 语言文件不存在: ${filePath}`, 'yellow');
return {};
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
} catch (error) {
log(`❌ 解析语言文件失败 ${filePath}: ${error}`, 'red');
return {};
}
}
/**
* 展平嵌套对象为点分隔的 key 路径
*/
function flattenKeys(obj: Record<string, any>, prefix = ''): Set<string> {
const keys = new Set<string>();
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// 递归处理嵌套对象
const nestedKeys = flattenKeys(value, fullKey);
nestedKeys.forEach(k => keys.add(k));
} else {
// 叶子节点
keys.add(fullKey);
}
}
return keys;
}
/**
* 检查 key 完整性
*/
function checkKeys(usedKeys: Set<string>, localeKeys: Record<Locale, Set<string>>): CheckResult {
const result: CheckResult = {
usedKeys,
localeKeys,
missingKeys: {
'zh-CN': [],
'zh-TW': [],
'en': [],
},
inconsistentKeys: [],
};
// 检查每个语言文件中缺失的 key
for (const locale of LOCALES) {
for (const key of usedKeys) {
if (!localeKeys[locale].has(key)) {
result.missingKeys[locale].push(key);
}
}
}
// 检查不一致的 key(某些语言有,某些没有)
const allLocaleKeys = new Set<string>();
LOCALES.forEach(locale => {
localeKeys[locale].forEach(key => allLocaleKeys.add(key));
});
for (const key of allLocaleKeys) {
const existsIn: Locale[] = [];
const missingIn: Locale[] = [];
for (const locale of LOCALES) {
if (localeKeys[locale].has(key)) {
existsIn.push(locale);
} else {
missingIn.push(locale);
}
}
// 如果某些语言有,某些没有,则不一致
if (existsIn.length > 0 && existsIn.length < LOCALES.length) {
result.inconsistentKeys.push({
key,
existsIn,
missingIn,
});
}
}
return result;
}
/**
* 打印检查报告
*/
function printReport(result: CheckResult): void {
log('\n=== 多语言 Key 检查报告 ===\n', 'cyan');
// 统计信息
log('📊 统计信息:', 'blue');
log(` - 代码中使用的 key 数量: ${result.usedKeys.size}`);
log(` - zh-CN 语言文件 key 数量: ${result.localeKeys['zh-CN'].size}`);
log(` - zh-TW 语言文件 key 数量: ${result.localeKeys['zh-TW'].size}`);
log(` - en 语言文件 key 数量: ${result.localeKeys['en'].size}`);
log('');
// 缺失的 key
let hasMissing = false;
for (const locale of LOCALES) {
if (result.missingKeys[locale].length > 0) {
hasMissing = true;
log(`❌ 缺失的 Key (代码中使用但 ${locale} 语言文件中不存在):`, 'red');
result.missingKeys[locale].forEach(key => {
log(` - ${key}`, 'red');
});
log('');
}
}
// 不一致的 key
if (result.inconsistentKeys.length > 0) {
log('⚠️ 不一致的 Key (某些语言文件有,某些没有):', 'yellow');
result.inconsistentKeys.forEach(({ key, existsIn, missingIn }) => {
log(` - ${key}`, 'yellow');
log(` 存在于: ${existsIn.join(', ')}`, 'yellow');
log(` 缺失于: ${missingIn.join(', ')}`, 'yellow');
});
log('');
}
// 总结
const hasErrors = hasMissing || result.inconsistentKeys.length > 0;
if (hasErrors) {
log('❌ 检查失败:发现缺失或不一致的 key', 'red');
} else {
log('✅ 检查通过:所有 key 都完整且一致', 'green');
}
}
/**
* 主函数
*/
function main(): void {
// 脚本位于 .cursor/skills/frontend/check-i18n-keys/scripts/,向上到项目根
const scriptDir = __dirname;
const skillDir = path.resolve(scriptDir, '..');
const frontendSkillDir = path.resolve(skillDir, '..');
const repoRoot = path.resolve(frontendSkillDir, '../../..');
const frontendDir = path.join(repoRoot, 'frontend');
const localesDir = path.join(frontendDir, 'src', 'locales');
// 检查目录是否存在
if (!fs.existsSync(frontendDir)) {
log(`❌ 前端目录不存在: ${frontendDir}`, 'red');
process.exit(1);
}
if (!fs.existsSync(localesDir)) {
log(`❌ 语言文件目录不存在: ${localesDir}`, 'red');
process.exit(1);
}
log('🔍 开始检查多语言 key...\n', 'cyan');
// 1. 提取代码中使用的 key
log('📝 扫描代码文件...', 'blue');
const usedKeys = extractKeysFromCode(frontendDir);
log(` 找到 ${usedKeys.size} 个使用的 key\n`, 'green');
// 2. 加载语言文件
log('📚 加载语言文件...', 'blue');
const localeKeys: Record<Locale, Set<string>> = {
'zh-CN': new Set(),
'zh-TW': new Set(),
'en': new Set(),
};
for (const locale of LOCALES) {
const localeObj = loadLocaleFile(localesDir, locale);
localeKeys[locale] = flattenKeys(localeObj);
log(` ${locale}: ${localeKeys[locale].size} 个 key`, 'green');
}
log('');
// 3. 检查
log('🔎 检查 key 完整性...', 'blue');
const result = checkKeys(usedKeys, localeKeys);
// 4. 输出报告
printReport(result);
// 5. 退出码
const hasErrors =
result.missingKeys['zh-CN'].length > 0 ||
result.missingKeys['zh-TW'].length > 0 ||
result.missingKeys['en'].length > 0 ||
result.inconsistentKeys.length > 0;
process.exit(hasErrors ? 1 : 0);
}
// 运行
main();
@@ -0,0 +1,680 @@
{
"name": "cursor-skill-frontend-scripts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-skill-frontend-scripts",
"version": "1.0.0",
"dependencies": {
"glob": "^13.0.1"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@types/node": {
"version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz",
"integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.1.2",
"minipass": "^7.1.2",
"path-scurry": "^2.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/lru-cache": {
"version": "11.2.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/minimatch": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.1"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
@@ -0,0 +1,16 @@
{
"name": "cursor-skill-frontend-scripts",
"version": "1.0.0",
"type": "module",
"scripts": {
"check-i18n": "tsx check-i18n-keys.ts"
},
"dependencies": {
"glob": "^13.0.1"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"downlevelIteration": true,
"types": ["node"]
},
"include": ["*.ts"],
"exclude": ["node_modules", "dist"]
}
+1 -1
View File
@@ -19,7 +19,7 @@ backend/gradle-app.setting
backend/.gradle
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
# backend/gradle/wrapper/gradle-wrapper.jar
polyhub/
# Kotlin
*.kt.bak
*.class
-37
View File
@@ -1,37 +0,0 @@
# PolyHermes 动态更新功能 - 遗漏检查与修复报告
## 检查时间
2026-01-21 03:00
## ✅ 已发现并修复的遗漏
### 1. docker-compose.prod.yml 环境变量
- **问题**: 生产环境部署文件缺少 `ALLOW_PRERELEASE``GITHUB_REPO`
- **修复**: 已添加到 `docker-compose.prod.yml`
### 2. 后端权限验证端点
- **问题**: `/api/auth/verify` 端点缺失,导致 Python 更新服务无法验证管理员权限。
- **修复**: 已在 `AuthController` 中添加 `/verify` 接口,仅允许 ADMIN 角色访问。
### 3. README.md 文档
- **问题**: 未提及新功能。
- **修复**: 已在 README 中添加"动态更新"功能说明及文档链接。
### 4. Docker Python 依赖优化
- **问题**: 使用 `pip install` 可能导致依赖冲突或安装缓慢。
- **修复**: 替换为 `apt-get install python3-flask python3-requests`,使用系统包更稳定、快速,且减小镜像体积。
---
## 🏁 最终状态
所有已知的遗漏都已检查并修复。系统现已准备好进行集成测试。
### 建议测试步骤
1. **本地构建测试**: `./deploy.sh` 验证 Dockerfile 更改(系统包安装)。
2. **后端测试**: 验证 `/api/auth/verify` 接口(需登录并在 Header 带上 Token)。
3. **流程测试**: 按计划进行 Pre-release 测试。
---
**状态**: ✅ **全功能就绪,已加固**
-301
View File
@@ -1,301 +0,0 @@
# PolyHermes 动态更新功能实施完成总结
## ✅ 全部完成!
**实施时间**: 2026-01-21
**总文件修改**: 12个
**前端新增**: 1个组件
**后端新增**: 1个服务
**总代码行数**: 约2000行
---
## 📂 文件清单
### 后端实施(已完成)
1.`Dockerfile` - 混合编译方案
2.`docker/update-service.py` - P Python Flask 更新服务(573行)
3.`docker/start.sh` - 启动3个进程
4.`docker/nginx.conf` - Nginx 代理配置
5.`docker-compose.yml` - 环境变量
6.`docker-compose.test.yml` - 测试环境
7.`.github/workflows/docker-build.yml` - CI/CD
### 前端实施(已完成)
8.`frontend/src/pages/SystemUpdate.tsx` - 系统更新组件(334行)
9.`frontend/src/pages/SystemSettings.tsx` - 集成到系统设置
### 文档
10.`docs/zh/DYNAMIC_UPDATE.md` - 完整技术文档
11.`docs/zh/IMPLEMENTATION_SUMMARY.md` - 实施总结
12.`verify-implementation.sh` - 验证脚本
---
## 🎯 核心功能
### 1. 混合编译策略
- **GitHub Actions**: 编译1次,8分钟完成
- **本地 deploy.sh**: 完全兼容,Docker内编译
- **构建参数**: `BUILD_IN_DOCKER` 控制编译位置
### 2. Pre-release 测试
- 测试版本不推送 `latest` 标签
- 测试版本不触发 Telegram 通知
- 环境变量 `ALLOW_PRERELEASE=true` 启用检测
### 3. 更新流程
```
检查版本 → 下载更新包 → 备份 → 替换文件 → 重启服务 → 健康检查 → 回滚(失败时)
```
### 4. 架构特点
- **Nginx 直接代理**: `/api/update/*` → Python:9090
- **权限验证**: Python 调用后端 `/api/auth/verify`
- **独立服务**: 更新服务与主应用分离
- **版本追踪**: `/app/version.json`
### 5. 前端UI
- 实时进度显示
- 版本对比
- Release Notes 展示
- 一键升级
- 自动刷新
---
## 🚀 使用流程
### 开发测试(Pre-release
```bash
# 1. 提交代码
git add .
git commit -m "feat: 动态更新功能"
git push origin dynamic_load
# 2. 创建测试 tag
git tag v1.3.0-beta
git push origin v1.3.0-beta
# 3. GitHub 创建 Pre-release
# - Tag: v1.3.0-beta
# - ✅ 勾选 "This is a pre-release"
# - 发布
# 4. GitHub Actions 自动执行
# - 编译前后端
# - 打包更新包
# - 上传到 Release Assets
# - 构建 Docker 镜像(仅 v1.3.0-beta 标签)
# -❌不推送 latest
# - ❌ 不发送 Telegram
# 5. 测试环境部署
docker pull wrbug/polyhermes:v1.3.0-beta
docker-compose -f docker-compose.test.yml up -d
# 6. 测试更新功能
# - 访问系统设置 → 系统更新
# - 点击"检查更新"(应该检测到 v1.3.0-beta
# - 点击"立即升级"
# - 验证更新流程
```
### 生产发布
```bash
# 测试通过后,创建正式版本
git tag v1.3.0
git push origin v1.3.0
# GitHub 创建 Release
# - Tag: v1.3.0
# - ❌ 不勾选 "pre-release"
# - 发布
# GitHub Actions 自动执行
# - 编译前后端
# - 打包更新包
# - 上传到 Release Assets
# - 构建 Docker 镜像(v1.3.0 + latest
# - ✅ 推送 latest
# - ✅ 发送 Telegram 通知
# 生产环境更新
# 1. 用户访问系统设置 → 系统更新
# 2. 点击"检查更新"
# 3. 点击"立即升级"
# 4. 等待30-60秒
# 5. 页面自动刷新
```
---
## 📋 验证清单
运行验证脚本:
```bash
./verify-implementation.sh
```
**预期输出**
```
========================================
PolyHermes 动态更新功能验证
========================================
📋 检查文件...
✅ Dockerfile
✅ docker/update-service.py
✅ docker/start.sh
✅ docker/nginx.conf
✅ docker-compose.yml
✅ docker-compose.test.yml
✅ .github/workflows/docker-build.yml
✅ docs/zh/DYNAMIC_UPDATE.md
📋 检查关键配置...
✅ Dockerfile 包含 BUILD_IN_DOCKER 参数
✅ Dockerfile 安装 Python
✅ Nginx 配置包含更新服务代理
✅ docker-compose.yml 包含 ALLOW_PRERELEASE
✅ GitHub Actions 包含 Pre-release 检测
✅ GitHub Actions 包含后端编译步骤
📋 检查 Python 语法...
✅ update-service.py 语法正确
========================================
✅ 验证通过!所有检查项正常
========================================
```
---
## ⚠️ 注意事项
### 必须检查的端点
1. **健康检查端点**: `/api/system/health`
- 用于检查后端服务是否正常
- 如果不存在,需要修改 `Dockerfile``start.sh` 中的健康检查URL
2. **权限验证端点**: `/api/auth/verify`
- 用于验证管理员权限
- 如果不存在,有两个选择:
- 在后端创建此端点
- 或修改 `update-service.py` 中的权限验证逻辑
---
## 🎨 前端UI特性
- ✅ 当前版本显示
- ✅ 检查更新按钮
- ✅ 更新信息展示(版本、发布时间、Release Notes
- ✅ 实时进度条(0-100%
- ✅ 状态消息显示
- ✅ 一键升级按钮
- ✅ 错误处理和显示
- ✅ 更新成功后自动刷新
- ✅ 使用说明提示
---
## 📚 API 文档
### 前端调用的API
| 端点 | 方法 | 说明 | 权限 |
|------|------|------|------|
| `/api/update/version` | GET | 获取当前版本 | 无 |
| `/api/update/check` | GET | 检查更新 | 无 |
| `/api/update/execute` | POST | 执行更新 | Admin |
| `/api/update/status` | GET | 获取更新状态 | 无 |
| `/api/update/logs` | GET | 获取更新日志 | Admin |
### 响应格式
```json
{
"code": 0,
"data": {
...
},
"message": "success"
}
```
---
## 🐛 故障排查
### 问题1:健康检查失败
**错误信息**: `后端服务启动超时`
**解决方案**:
```bash
# 检查健康检查端点
curl http://localhost:8000/api/system/health
# 如果404,修改 Dockerfile 和 start.sh
# 将 /api/system/health 改为实际存在的端点
```
### 问题2:权限验证失败
**错误信息**: `需要管理员权限`
**解决方案**:
1. 确保前端已登录且有 Admin Token
2. 检查 `/api/auth/verify` 端点是否存在
3. 或修改 `update-service.py` 的权限验证逻辑
### 问题3:更新包下载失败
**错误信息**: `下载更新包失败`
**可能原因**:
- GitHub Release 未发布
- 更新包文件名不符合规范
- 网络连接问题
**解决方案**:
```bash
# 检查 Release Assets
curl https://api.github.com/repos/WrBug/PolyHermes/releases/latest
# 确保文件名格式:polyhermes-{tag}-update.tar.gz
```
---
## 📊 性能指标
| 指标 | 数值 |
|------|------|
| **GitHub Actions 构建时间** | ~8分钟 |
| **更新包大小** | ~50MB |
| **更新总时长** | 30-60秒 |
| **下载时间** | 5-15秒(依网络)|
| **备份时间** | 2-5秒 |
| **解压时间** | 2-3秒 |
| **重启时间** | 10-15秒 |
| **健康检查** | 最多30秒 |
---
##✅ 实施完成状态
**后端**: ✅ 100% 完成
**前端**: ✅ 100% 完成
**文档**: ✅ 100% 完成
**测试**: ⏳ 待验证
---
**状态**: 🎉 **实施完成,准备测试!**
**下一步**: 创建 Pre-release 进行测试验证
-31
View File
@@ -1,31 +0,0 @@
# PolyHermes 动态更新功能 - 遗漏检查报告
## 检查时间
2026-01-21 01:46
## 已发现并修复的遗漏
### 1. ✅ docker-compose.prod.yml 缺少环境变量
**问题**: 生产环境配置文件缺少动态更新相关的环境变量
**修复**: 已添加 `ALLOW_PRERELEASE``GITHUB_REPO` 环境变量
### 2. ✅ 后端缺少权限验证端点
**问题**: Python 更新服务需要调用 `/api/auth/verify` 验证管理员权限,但该端点不存在
**修复**: 已在 `AuthController.kt` 中添加 `verify` 端点
## 继续检查项目
### 3. 备份文件检查
检查是否有遗留的备份文件需要清理...
### 4. .gitignore 文件
检查是否需要添加临时文件到 .gitignore...
### 5. 前端国际化
检查是否需要为系统更新添加多语言支持...
### 6. README 文档
检查是否需要更新 README 说明新功能...
### 7. 依赖检查
检查 Python 依赖是否完整(Flask, requests...
+23
View File
@@ -165,6 +165,29 @@
**部署步骤**
#### ⚡ 一键安装(最快,推荐新用户)
**使用 curl(推荐):**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
```
**使用 wget**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
```
这个命令会自动:
- 📁 创建专用工作目录 `~/polyhermes`
- ✅ 自动检查 Docker 环境
- ⚙️ 交互式配置所有参数(支持回车使用默认值)
- 🔐 自动生成安全的随机密钥
- 🚀 自动下载最新镜像并部署
---
#### 📋 其他部署方式
1. **使用 Docker Hub 镜像(推荐,生产环境首选)**
**方式 1:独立部署(无需 clone 代码,推荐)**
+23
View File
@@ -164,6 +164,29 @@ Deploy both frontend and backend together in a single Docker container, using Ng
**Deployment Steps**:
#### ⚡ One-Liner Installation (Fastest, Recommended for New Users)
**Using curl (Recommended):**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
```
**Using wget:**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
```
This command will automatically:
- 📁 Create dedicated working directory `~/polyhermes`
- ✅ Automatically check Docker environment
- ⚙️ Interactive configuration for all parameters (supports pressing Enter for defaults)
- 🔐 Automatically generate secure random keys
- 🚀 Automatically download latest images and deploy
---
#### 📋 Alternative Deployment Methods
1. **Using Docker Hub Images (Recommended, Production First Choice)**
**Method 1: Standalone Deployment (No code clone required, Recommended)**
-971
View File
@@ -1,971 +0,0 @@
# v1.1.10
## 🚀 主要功能
### 📢 推送已过滤订单功能
- **新增推送已过滤订单功能(pushFilteredOrders**,默认关闭
- 支持在模板和跟单配置中配置是否推送被过滤的订单通知
- 开启后,当订单因过滤条件(价格区间、订单深度、价差、仓位限制等)被过滤时,会发送 Telegram 通知
- 帮助用户了解哪些订单被过滤以及过滤原因
- **数据库迁移**
- 添加 `push_filtered_orders` 字段到 `copy_trading_templates`
- 添加 `push_filtered_orders` 字段到 `copy_trading`
- 迁移脚本:`V24__add_push_filtered_orders_to_templates.sql`
- **后端实现**
- 在实体类(`CopyTradingTemplate``CopyTrading`)中添加 `pushFilteredOrders` 字段
- 在 DTO 中添加字段支持(创建、更新、查询)
- 在 Service 中处理字段的创建、更新和传递
- 在发送过滤订单通知时检查 `pushFilteredOrders` 字段,只有为 `true` 时才发送
- **前端实现**
- 在模板新增、编辑、推送页面添加"推送已过滤订单"开关
- 在跟单配置新增、编辑页面添加"推送已过滤订单"开关
- 从模板创建跟单配置时,自动填充 `pushFilteredOrders` 字段
- 支持多语言(中文、繁体中文、英文)
### 🔍 优化订单列表筛选功能
- **支持按市场标题搜索**
- 买入订单列表:添加市场标题筛选,移除方向筛选
- 卖出订单列表:添加市场标题筛选,将方向筛选改为状态筛选
- 已成交订单列表:添加市场标题筛选和市场列显示
- **改进分组体验**
- 记录用户的分组偏好到 localStorage,跨会话持久化
- 所有搜索输入框添加 0.5 秒防抖优化,提升性能
- **后端优化**
- `MarketGroupedOrdersRequest` 添加 `marketId``marketTitle` 字段
- 买入/卖出订单分组接口支持市场 ID 模糊匹配和市场标题关键字筛选
### ⚡ 优化跟单关系统计性能
- **移除未实现盈亏计算**
- 移除未实现盈亏和持仓价值的计算逻辑
- 总盈亏现在仅包含已实现盈亏,计算更准确
- 简化盈亏百分比计算,仅基于已实现盈亏
- **性能提升**
- 从 1+N 次网络请求减少到 0 次
- 删除不再使用的方法和依赖注入
- 统计查询速度显著提升
### 🐳 Docker 容器时区配置
- **支持通过 .env 自定义时区**:
-`docker-compose.yml``docker-compose.prod.yml` 中添加时区环境变量配置
- 支持通过 `TZ` 环境变量自定义容器时区
- 默认使用系统时区
## 🐛 Bug 修复
### 修复跟单配置更新时清空可选字段无法保存的问题
- **问题**:修改跟单配置时,清空价格区间、最大仓位数量、截止时间等可选字段后,无法保存到数据库
- **修复**
- 修复价格区间(`minPrice`/`maxPrice`)清空后无法保存的问题
- 修复最大仓位数量(`maxPositionCount`)清空后无法保存的问题
- 修复市场截止时间(`maxMarketEndDate`)清空后无法保存的问题
- 修复其他可选字段(`minOrderDepth`/`maxSpread`/`maxPositionValue`)清空后无法保存的问题
- **实现方案**
- 前端:清空字段时传空字符串或 `-1` 标记,让后端识别为清空操作
- 后端:处理空字符串和 `-1` 标记,正确设置为 `null` 以清空字段
### 修复新建和编辑页面的截止时间输入框交互问题
- **问题**:删除截止时间输入框内容后,失去焦点会自动填充 1
- **修复**
-`min``1` 改为 `0`,允许空值
- 优化 `onChange` 处理,当值为 `0``null``undefined` 时设置为 `undefined`(清空)
- 添加 `onBlur` 处理,确保失去焦点时如果值为空或 `0`,设置为 `undefined`
- 修改 `parser`,空值时返回空字符串而不是 `0`
### 修复订单通知重复发送和时间显示问题
- **修复并发导致的重复通知问题**:
-`OrderStatusUpdateService` 中实现双重检查机制
- 先保存订单标记为已发送,再重新查询数据库检查
- 防止定时任务并发时重复发送同一订单的通知
- 同时修复买入订单和卖出订单的通知逻辑
- **修复 Telegram 通知时间显示**
- `TelegramNotificationService.sendOrderSuccessNotification` 添加 `orderTime` 参数
- 使用订单的 `createdAt` 时间戳作为通知显示时间
- 而不是使用当前通知发送时间
- 更准确反映订单的实际创建时间
### 修复新建跟单配置时 pushFilteredOrders 字段未生效的问题
- **问题**:新建跟单配置时,即使设置了 `pushFilteredOrders: true`,也没有生效
- **修复**
- 修复手动输入模式下 `pushFilteredOrders` 被硬编码为 `false` 的问题
- 修复从模板填充表单时未加载 `pushFilteredOrders` 的问题
- 添加 `CopyTradingTemplate` 接口的 `pushFilteredOrders` 字段定义
## 📝 技术细节
### 数据库变更
- **迁移脚本**`V24__add_push_filtered_orders_to_templates.sql`
- **变更内容**
- `copy_trading_templates.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
- `copy_trading.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
- **自动执行**:升级时会自动执行迁移脚本
### API 变更
- **无新增接口**
- **无移除接口**
- **请求/响应变更**
- `CopyTradingCreateRequest` 添加 `pushFilteredOrders` 字段
- `CopyTradingUpdateRequest` 添加 `pushFilteredOrders` 字段
- `TemplateCreateRequest` 添加 `pushFilteredOrders` 字段
- `TemplateUpdateRequest` 添加 `pushFilteredOrders` 字段
- `MarketGroupedOrdersRequest` 添加 `marketId``marketTitle` 字段
### 前端变更
- **新增字段**
- `CopyTradingTemplate` 接口添加 `pushFilteredOrders` 字段
- **组件更新**
- 模板新增、编辑、推送页面添加"推送已过滤订单"开关
- 跟单配置新增、编辑页面添加"推送已过滤订单"开关
- 优化截止时间输入框交互逻辑
- **多语言支持**
- 添加中文、繁体中文、英文翻译
## 📊 变更统计
- **提交数量**6 个提交
- **文件变更**30 个文件
- **代码变更**+651 行 / -555 行(净增加 96 行)
### 详细文件变更
**后端变更**
- `CopyTrading.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
- `CopyTradingTemplate.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
- `CopyTradingDto.kt` - 添加 `pushFilteredOrders` 字段支持(+3 行)
- `CopyTradingTemplateDto.kt` - 添加 `pushFilteredOrders` 字段支持(+10 行)
- `CopyTradingService.kt` - 处理 `pushFilteredOrders` 字段和清空字段逻辑(+93 行)
- `CopyTradingTemplateService.kt` - 处理 `pushFilteredOrders` 字段(+8 行)
- `CopyOrderTrackingService.kt` - 检查 `pushFilteredOrders` 字段发送通知(+42 行)
- `CopyTradingStatisticsService.kt` - 优化统计性能(-328 行)
- `OrderStatusUpdateService.kt` - 修复重复通知问题(+142 行)
- `TelegramNotificationService.kt` - 添加订单时间参数(+17 行)
- `V24__add_push_filtered_orders_to_templates.sql` - 数据库迁移脚本(+13 行)
**前端变更**
- `AddModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+40 行)
- `EditModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+59 行)
- `TemplateAdd.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
- `TemplateEdit.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
- `TemplateList.tsx` - 添加 `pushFilteredOrders` 字段(+13 行)
- `BuyOrdersTab.tsx` - 优化筛选功能(+94 行)
- `SellOrdersTab.tsx` - 优化筛选功能(+77 行)
- `MatchedOrdersTab.tsx` - 优化筛选功能(+26 行)
- `types/index.ts` - 添加 `pushFilteredOrders` 字段定义(+6 行)
- `locales/*/common.json` - 添加多语言翻译(+31 行)
**配置文件变更**
- `docker-compose.yml` - 添加时区配置(+5 行)
- `docker-compose.prod.yml` - 添加时区配置(+5 行)
- `Dockerfile` - 优化构建配置(+4 行)
## 🔄 主要提交
```
9c303e0 feat: 添加推送已过滤订单功能并修复相关问题
cb8e469 fix: 修复跟单配置更新时清空可选字段无法保存的问题
279806d feat: 优化订单列表筛选功能,支持按市场标题搜索并改进分组体验
90fa487 refactor: 移除未实现盈亏计算以优化跟单关系统计性能
b58bb26 feat: 添加 Docker 容器时区配置,支持通过 .env 自定义
6af76c4 fix: 修复订单通知重复发送和时间显示问题
```
## 🎯 升级建议
1. **数据库迁移**:本次版本包含数据库迁移脚本,升级时会自动执行
- 自动添加 `push_filtered_orders` 字段到模板表和跟单配置表
- 现有数据不受影响,新字段默认值为 `false`
2. **配置更新**
- 可选:在 `.env` 文件中添加 `TZ` 环境变量自定义容器时区
- 无需其他配置变更
3. **兼容性**
- 完全向后兼容,不影响现有功能
- API 变更都是新增字段,不影响现有调用
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.10`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.10)
- [完整更新日志](https://github.com/WrBug/PolyHermes/compare/v1.1.9...v1.1.10)
---
**发布日期**2026-01-12
---
# v1.1.9
## 🐛 Bug 修复
### 修复盈亏统计计算错误问题
- **问题**:在仓位检查时,刚创建的订单可能因仓位更新延迟被误判为已卖出,导致盈亏统计计算错误
- **修复**
- 优化自动卖出订单筛选逻辑,在 SQL 层直接过滤创建时间超过阈值的订单
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` Repository 方法,提高查询效率
- 统一使用 2 分钟阈值保护刚创建的订单,避免误判
- 两个场景都使用 SQL 过滤:
* 场景1:仓位不存在时,延迟检测使用 SQL 过滤
* 场景2:仓位部分存在时,FIFO 匹配使用 SQL 过滤
- **优化效果**
- SQL 层面直接过滤,减少数据传输,提高查询效率
- 代码更简洁,逻辑更清晰
- 刚创建的订单(< 2 分钟)不会被误判为已卖出
- 确保盈亏统计计算的准确性
## 📝 技术细节
- **Repository 变更**
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` 方法
- 在 SQL 查询中添加 `createdAt < :thresholdTime` 条件过滤
- **Service 变更**
- `PositionCheckService` 中两个场景都使用 SQL 过滤替代内存过滤
- 统一使用 2 分钟(120000 毫秒)作为时间阈值
## 📊 变更统计
- **提交数量**1 个提交
- **文件变更**2 个文件
- **代码变更**+57 行 / -19 行(净增加 38 行)
### 详细文件变更
**后端变更**
- `CopyOrderTrackingRepository.kt` - 新增 SQL 查询方法(+18 行)
- `PositionCheckService.kt` - 优化订单筛选逻辑(+39 行 / -19 行)
## 🔄 主要提交
```
6ad4024 fix: 优化自动卖出订单筛选逻辑,避免刚创建的订单被误判
```
## 🎯 升级建议
1. **数据库迁移**:无需数据库迁移,可直接升级
2. **配置更新**:无需配置变更
3. **兼容性**:完全向后兼容,不影响现有功能
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.9`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.9)
---
# v1.1.8
## 🚀 主要功能
### ⚡ Polymarket Activity WebSocket 双重监听方案
- **新增 PolymarketActivityWsService**:通过 Activity WebSocket 实时监听 Leader 交易
- 延迟 < 100ms,适合快速跟单场景
- 订阅全局 activity 交易流,客户端过滤 Leader 地址
- 支持动态添加/移除 Leader 监听
- 地址筛选忽略大小写,提高匹配准确性
- **双重监听机制**
- Activity WebSocket(低延迟,< 100ms):作为主要监听方式
- On-Chain WebSocket(高可靠性,~2-3s):作为兜底监听方式
- 自动去重,避免重复处理同一笔交易
- **协议支持**
- 支持 `connection_id` 字段
- 优先使用消息中的 `outcomeIndex` 字段,fallback 到从 `outcome` 解析
- **参考文档**
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
### 🔧 支持同一用户创建多个相同 Leader 的跟单配置
- **移除唯一约束**:允许同一用户创建多个跟单同一个 Leader 的配置
- **应用场景**:支持不同参数(比例、过滤条件等)的多配置跟单
- **数据库迁移**`V23__remove_unique_constraint_from_copy_trading.sql`
### 🎯 市场截止时间筛选功能
- **新增市场截止时间过滤**:支持设置跟单配置的最大市场截止时间
- **自动过滤过期市场**:系统会自动跳过超过设定截止时间的市场
- **数据库字段**`max_market_end_date`(时间戳,单位:秒)
- **数据库迁移**`V22__add_max_market_end_date_to_copy_trading.sql`
### 🔍 关键字过滤功能
- **新增关键字过滤**:支持在黑名单或白名单模式下过滤市场标题关键字
- **过滤模式**
- `DISABLED`:禁用关键字过滤
- `BLACKLIST`:黑名单模式(包含关键字的市场会被过滤)
- `WHITELIST`:白名单模式(只允许包含关键字的市场)
- **多关键字支持**:支持多个关键字,以 JSON 数组格式存储
- **数据库字段**`keyword_filter_mode``keywords`JSON 数组)
- **数据库迁移**`V20__add_keyword_filter.sql`
### 📊 订单列表功能重构
- **按市场分组显示**:订单列表按市场分组,便于查看和管理
- **市场信息展示**
- 显示市场标题和 slug
- 支持跳转到 Polymarket 市场页面
- 显示市场基本信息(标题、slug、截止时间等)
- **订单 ID 复制功能**:一键复制订单 ID,方便查询和调试
- **UI/UX 优化**
- 优化订单列表展示样式
- 改进移动端适配
### 📈 市场信息管理和缓存优化
- **新增 MarketService**:统一管理市场信息查询和缓存
- 使用 LRU 缓存提高查询性能
- 支持从 Gamma API 和 CLOB API 查询市场信息
- 自动缓存市场信息,减少 API 调用
- **新增 Market 实体和表**:持久化存储市场信息
- 存储市场标题、slug、eventSlug、endDate 等基本信息
- 支持通过 marketId 快速查询市场信息
- **数据库迁移**
- `V19__create_markets_table.sql`:创建市场信息表
- `V21__add_event_slug_to_markets.sql`:添加 eventSlug 字段
- **市场信息轮询服务**:定期更新市场信息,保持数据新鲜度
## 🐛 Bug 修复
### 前端 TypeScript 类型错误修复
- **修复 InputNumber parser 类型错误**`parser` 函数应返回 `number` 而不是 `string`
- **修复文件**
- `src/pages/CopyTradingOrders/AddModal.tsx`
- `src/pages/CopyTradingOrders/EditModal.tsx`
## 📝 文档更新
- **新增双重监听方案文档**:详细说明 Activity WebSocket 和 On-Chain WebSocket 的双重监听机制
- **新增 Activity WebSocket API 文档**:详细的 API 格式和消息结构说明
- **更新开发规范**JSON 解析规范和 Data Class 规范
## 📊 统计信息
- **61 个文件被修改**
- **+5125 行新增代码**
- **-1942 行删除代码**
- **净增加 3183 行代码**
## 🔄 主要提交
```
d376a82 feat: 添加市场信息管理和订单ID复制功能
2af2c0e feat: 订单列表按市场分组并支持跳转到Polymarket
9ed5190 feat: 添加关键字过滤功能并优化市场 slug 处理
0327eaf feat: 添加市场截止时间筛选功能
a16b6fc feat: 支持同一用户创建多个相同 leader 的跟单配置
19508dc feat: 实现 Polymarket Activity WebSocket 双重监听方案
```
## 🎯 升级建议
1. **数据库迁移**:确保执行所有数据库迁移脚本(V19-V23)
2. **配置更新**:新版本的 `application.properties` 中添加了 Activity WebSocket URL 配置
3. **环境变量**:如果使用 Docker 部署,建议更新 `docker-compose.yml` 中的配置
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.8`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.8)
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
---
# v1.1.7
## 🚀 主要功能
### 💰 Polymarket Maker Rebates Program 费率支持
- **新增费率查询 API 接口** (`getFeeRate`)
- 支持动态查询 Maker Rebates Program 费率
- 修正 API 返回字段名:使用 `base_fee` 而非 `fee_rate_bps`(与 TypeScript clob-client 一致)
- **动态费率获取**
- 在所有订单创建处动态获取费率:
* 跟单买入订单 (`processBuyTrade`)
* 跟单卖出订单 (`matchSellOrder`)
* 账户卖出订单 (`sellPosition`)
- 费率获取失败时降级到默认值 "0",确保系统可用性
- 添加详细的日志记录,便于监控和调试
- **参考文档**: https://docs.polymarket.com/developers/market-makers/maker-rebates-program
### 🔧 Docker 部署优化
- **日志级别环境变量支持**
-`application.properties` 中支持通过 `LOG_LEVEL_ROOT``LOG_LEVEL_APP` 环境变量配置日志级别
-`docker-compose.yml``docker-compose.prod.yml` 中添加日志级别环境变量配置
-`deploy.sh``.env` 模板中添加日志级别配置说明
- 支持通过环境变量动态配置日志级别,无需修改配置文件
- 默认值:`root=INFO`, `app=DEBUG`
## 🐛 Bug 修复
### 修复市场条件查询的 RPC 调用错误
- **问题**:使用错误的函数签名 `conditions(bytes32)` 导致 RPC 调用失败(execution reverted
- **修复**
- 将错误的 `conditions(bytes32)` 函数调用改为正确的 `getOutcomeSlotCount(bytes32)``payoutDenominator(bytes32)` 函数调用
- 修复 `BlockchainService.getCondition` 方法,使用正确的 ConditionalTokens 合约函数签名
- 改进 `MarketPriceService` 的错误处理:当链上查询出现 RPC 错误时,降级到 CLOB API 或 Gamma API 查询,而不是直接抛出异常,提高容错性
### 修复 RPC 错误时误创建自动卖出记录的问题
- **问题**:当链上查询市场条件出现 RPC 错误(execution reverted)时,系统会误判为市场已卖出,创建错误的自动卖出记录
- **修复**
- 修改 `getPriceFromChainCondition` 返回 `Pair<BigDecimal?, Boolean>`,第二个值表示是否发生 RPC 错误
-`getCurrentMarketPrice` 中检测到 RPC 错误时抛出异常,`PositionCheckService` 会捕获并跳过该市场的处理
- 避免在市场不存在或尚未创建时误判为已卖出
## 📝 文档更新
### 更新 Telegram 群链接
- 将所有 Telegram 群链接统一更新为 `t.me/polyhermes`
- 更新了以下文件:
- `frontend/src/components/Layout.tsx` - 桌面端和移动端导航链接
- `RELEASE.md` - 相关链接
- `README.md``README_EN.md` - 相关链接部分
### 添加 Docker 版本徽章
- 在 README 和 README_EN.md 中添加动态 Docker 版本徽章
- 使用 shields.io 自动显示 Docker Hub 上 `wrbug/polyhermes` 镜像的最新版本
- 版本信息自动更新,无需手动维护
## 📊 变更统计
- **提交数量**5 个提交
- **文件变更**16 个文件
- **代码变更**+205 行 / -886 行(净减少 681 行)
### 详细文件变更
**后端变更**
- `PolymarketClobApi.kt` - 添加费率查询接口(+25 行)
- `AccountService.kt` - 在订单创建处添加动态费率获取(+11 行)
- `BlockchainService.kt` - 修复市场条件查询的 RPC 调用错误(+84 行)
- `MarketPriceService.kt` - 改进错误处理,支持降级到其他数据源(+36 行)
- `PolymarketClobService.kt` - 添加费率查询服务(+32 行)
- `CopyOrderTrackingService.kt` - 在跟单订单创建处添加费率获取(+34 行)
- `PositionCheckService.kt` - 修复 RPC 错误处理逻辑(+2 行)
- `application.properties` - 添加日志级别环境变量支持(+6 行)
**前端变更**
- `Layout.tsx` - 更新 Telegram 群链接(+4 行)
**配置文件变更**
- `docker-compose.yml` - 添加日志级别环境变量(+4 行)
- `docker-compose.prod.yml` - 添加日志级别环境变量(+4 行)
- `deploy.sh` - 添加日志级别配置说明(+5 行)
**文档变更**
- `README.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
- `README_EN.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
- `RELEASE.md` - 更新 Telegram 链接(+4 行)
- `docs/zh/smart-money-analysis.md` - 删除文档(-836 行)
## 🔧 技术细节
### API 变更
- **新增接口**
- `POST /api/clob/fee-rate` - 获取 Maker Rebates Program 费率(内部使用)
- **无移除接口**
### 环境变量变更
- **新增环境变量**
- `LOG_LEVEL_ROOT` - Root 日志级别(默认:INFO
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
### 合约调用修复
- **修复的函数调用**
-`conditions(bytes32)` 改为 `getOutcomeSlotCount(bytes32)``payoutDenominator(bytes32)`
- 使用正确的 ConditionalTokens 合约函数签名
- 参考:https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045#code
## 📝 升级说明
### 数据库升级
- **无需数据库迁移**:本次更新不涉及数据库结构变更
### 配置更新
- **可选配置**:新增日志级别环境变量,如不配置将使用默认值
- `LOG_LEVEL_ROOT=INFO`(默认)
- `LOG_LEVEL_APP=DEBUG`(默认)
### Docker 部署
- **推荐更新**:使用 Docker Hub 镜像部署的用户,建议更新到最新版本
```bash
docker pull wrbug/polyhermes:latest
docker-compose -f docker-compose.prod.yml up -d
```
## 🔗 相关链接
- **GitHub 仓库**https://github.com/WrBug/PolyHermes
- **Twitter**@polyhermes
- **Telegram 群组**https://t.me/polyhermes
---
**发布日期**2026-01-07
---
# v1.1.5
## 🔧 功能优化与改进
### 前端优化
#### 优化 InputNumber 输入框格式化
- 优化数值输入框的格式化逻辑,修正正则表达式以正确处理整数显示
- 更新所有相关 InputNumber 组件的 formatter 函数,确保显示准确性
- 影响的组件:CopyTradingAdd、CopyTradingEdit、EditModal、TemplateAdd、TemplateEdit、TemplateList
- 影响范围:跟单配置、模板配置中的所有数值输入框
#### 优化数字显示格式
- 添加 `formatNumber` 工具函数,自动去除小数尾随零(如 100.00 → 100
- 统一所有数值输入框的显示格式,提升用户体验
### 后端优化
#### 优化按比例跟单金额计算逻辑
- 优化按比例计算的订单金额处理,使用向上取整确保满足最小限制要求
- 对订单金额进行向上取整处理(保留 2 位小数精度)
- 自动调整订单数量以满足最小限制要求
- 使用 `RoundingMode.CEILING` 确保金额满足最小限制
- 影响范围:按比例跟单的订单创建逻辑
- 技术细节:
- 扩展 `BigDecimal.div()` 扩展函数,支持指定精度和舍入模式
- 在 `CopyOrderTrackingService` 中优化金额计算和验证逻辑
#### 增强 copyRatio 精度支持
- 将 copyRatio 字段精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
- 支持更精确的跟单比例设置(最小 0.01%,最大 10000%
- 影响的实体:CopyTrading、CopyTradingTemplate
- 数据库迁移:新增 V18 迁移脚本,自动升级数据库字段精度
## 🔧 功能优化
### 移除刷新代理钱包接口
- **移除接口**
- `POST /api/accounts/refresh-proxy` - 刷新单个账户的代理地址
- `POST /api/accounts/refresh-all-proxies` - 刷新所有账户的代理地址
- **原因**:代理地址应在账户导入时自动计算,无需手动刷新
- **影响范围**AccountController、AccountService
- **向后兼容性**:这些接口已不再使用,移除不影响现有功能
### 前端跟单比例配置优化
- **最小比例**:从 10% 降低到 0.01%,支持更灵活的跟单比例设置
- **最大比例**:增加到 10000%,满足大比例跟单需求
- **显示格式**:比例模式显示为百分比(如 "100%" 而不是 "1x"
- **输入验证**:增强输入验证,确保比例在合理范围内
## 📊 变更统计
- **提交数量**3 个提交
- **文件变更**15 个文件
- **代码变更**+575 行 / -194 行(净增加 381 行)
### 详细文件变更
**后端变更**
- `AccountController.kt` - 移除刷新代理钱包接口(-59 行)
- `AccountService.kt` - 移除刷新代理钱包方法(-79 行)
- `CopyTrading.kt` - 增加 copyRatio 精度
- `CopyTradingTemplate.kt` - 增加 copyRatio 精度
- `CopyOrderTrackingService.kt` - 优化按比例跟单金额计算逻辑(+45 行)
- `MathExt.kt` - 扩展 div 函数支持精度和舍入模式(+20 行)
- `V18__increase_copy_ratio_precision.sql` - 数据库迁移脚本(+14 行)
**前端变更**
- `CopyTradingAdd.tsx` - 优化 formatter、优化比例配置(+106 行)
- `CopyTradingEdit.tsx` - 优化 formatter、优化比例配置(+106 行)
- `CopyTradingList.tsx` - 优化比例显示格式
- `CopyTradingOrders/EditModal.tsx` - 优化 formatter、优化比例配置(+106 行)
- `TemplateAdd.tsx` - 优化 formatter、优化比例配置(+65 行)
- `TemplateEdit.tsx` - 优化 formatter、优化比例配置(+65 行)
- `TemplateList.tsx` - 优化 formatter、优化比例配置(+65 行)
- `utils/index.ts` - 添加 formatNumber 工具函数(+31 行)
## 🔧 技术细节
### 数据库变更
- **迁移脚本**`V18__increase_copy_ratio_precision.sql`
- **变更内容**
- `copy_trading.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
- `copy_trading_templates.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
- **自动执行**:升级时会自动执行迁移脚本
### API 变更
- **移除接口**
- `POST /api/accounts/refresh-proxy`
- `POST /api/accounts/refresh-all-proxies`
- **无新增接口**
### 前端变更
- **工具函数**:新增 `formatNumber()` 函数,用于格式化数字显示
- **组件更新**:所有数值输入框统一使用新的 formatter 函数
- **显示优化**:跟单模式的比例显示为百分比格式
## 📝 升级说明
### 数据库升级
本次版本包含数据库迁移脚本,升级时会自动执行:
- 自动增加 `copy_ratio` 字段的精度
- 现有数据不受影响,精度升级是向后兼容的
### 配置变更
无需额外配置变更。
### 兼容性
- **向后兼容**:所有变更都是向后兼容的
- **API 兼容**:移除的接口不影响现有功能(这些接口已不再使用)
- **数据兼容**:数据库字段精度升级不会影响现有数据
## 🎯 主要改进
1. **优化输入框格式化**:优化数值输入框的显示逻辑
2. **优化跟单金额计算**:确保按比例跟单的金额满足最小限制要求
3. **提升精度支持**:支持更精确的跟单比例设置(0.01% - 10000%
4. **代码清理**:移除不再使用的刷新代理钱包接口
## 🔗 相关链接
- [GitHub Tag](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.5)
- [变更日志](https://github.com/WrBug/PolyHermes/compare/v1.1.4...v1.1.5)
## 🙏 致谢
感谢所有贡献者和测试用户的反馈与支持!
---
# v1.1.2
## 🚀 主要功能
### 🐛 修复内存泄漏问题
- 修复 Retrofit/OkHttpClient 实例重复创建导致的内存泄漏问题
- 为不需要认证的 API 创建共享的 OkHttpClient 实例(Gamma API、Data API、GitHub API 等)
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
- RPC API 按 RPC URL 缓存,Builder Relayer API 按 relayerUrl 缓存
- 添加 `@PreDestroy` 方法清理缓存,确保资源正确释放
- **效果**:内存占用从运行几小时后从 400MB 涨到 1GB+ 变为保持稳定,大幅减少内存占用
### 📊 市场价格服务优化
- 移除降级查询逻辑,仅保留链上 RPC 查询和 CLOB 订单簿查询
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
- 如果所有数据源都失败,抛出明确的异常信息
- 价格截位到 4 位小数(向下截断,不四舍五入)
- 简化代码逻辑,提高查询效率和准确性
### 🔧 代码架构优化
- 统一 Gson 使用,改为依赖注入方式
- 在 `GsonConfig` 中统一配置 Gson Beanlenient 模式)
- 所有 Service 类通过构造函数注入 Gson 实例
- 移除所有 `GsonConverterFactory.create()` 无参调用,统一使用注入的 Gson
- 提高代码一致性和可维护性
### 🗑️ 功能清理
- 移除下单失败存储数据库的功能
- 删除 `FailedTrade` 实体类和 `FailedTradeRepository`
- 从 `CopyOrderTrackingService` 中移除失败交易存储逻辑
- 创建 Flyway migration V16 删除 `failed_trade` 表
- 下单失败时仅记录日志,不再存储到数据库,简化数据模型
### 🚀 部署优化
- 自动使用当前分支名作为 Docker 版本号
- 分支名中的 `/` 自动替换为 `-`Docker tag 不支持 `/
- `docker-compose.yml` 启用 build args,从环境变量读取版本号
- 前端页面将显示当前分支名作为版本号
- 如果没有 Git 仓库或获取失败,使用默认值 `dev`
## 🐛 Bug 修复
### 修复 Flyway Migration 问题
- 恢复 V1 migration 文件,避免 checksum 不匹配
- 保持 `V1__init_database.sql` 的原有内容不变
- `failed_trade` 表的删除通过 V16 migration 处理
- 确保已有数据库的 migration checksum 保持一致
### 修复前端编译错误
- 修复 `PositionList.tsx` 中引用不存在的 `bestBid` 属性导致的编译错误
- 使用 `currentPrice` 替代 `bestBid`,确保前端代码可以正常编译
## 📚 文档更新
- 新增智能资金分析文档(`docs/zh/smart-money-analysis.md`
- 详细说明智能资金分析功能的使用方法和策略
## 🔧 技术改进
- 优化 `RetrofitFactory`,实现客户端实例缓存和复用
- 优化 `CopyOrderTrackingService`,移除失败交易相关逻辑
- 优化 `OrderStatusUpdateService`,增强订单状态更新功能
- 优化 `TelegramNotificationService`,改进通知逻辑
- 优化 `PositionCheckService`,简化代码结构
- 优化 `PolymarketClobService`,改进 API 调用逻辑
## 📦 数据库变更
- 删除 `failed_trade` 表(Migration: V16
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.2
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.1...v1.1.2
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## 📊 统计信息
- **文件变更**: 29 个文件
- **代码变更**: +1597 行 / -678 行
- **主要提交**: 8 个提交
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.2
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**https://t.me/polyhermes
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
---
# v1.1.1
## 🚀 主要功能
### 🔗 链上 WebSocket 监听优化
- 创建 `UnifiedOnChainWsService` 统一管理 WebSocket 连接,所有服务共享同一个连接
- 创建 `OnChainWsUtils` 工具类,提取公共的链上 WebSocket 相关功能
- 创建 `AccountOnChainMonitorService` 监听账户链上卖出和赎回事件
- 优化 `OnChainWsService`,复用公共代码,减少代码重复
- 支持通过链上 WebSocket 实时监听账户的卖出和赎回交易,自动更新订单状态
### 📊 市场状态查询优化
- 优化市场结算状态查询,优先使用链上查询 `ConditionalTokens.getCondition`
- 如果链上查询失败,自动降级到 Gamma API 查询
- 提供更实时和准确的市场结算结果
### 🔕 自动订单通知优化
- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_ 前缀)不再发送 Telegram 通知
- 优化 `OrderStatusUpdateService`,跳过自动生成订单的通知处理
- 减少不必要的通知,提升用户体验
## 🐛 Bug 修复
### 修复移动端 API 健康页面缺少数据显示
- 移动端添加 URL 地址显示
- 移动端添加状态文本显示(正常/异常/未配置)
- 移动端添加消息/状态信息显示
- 移动端和桌面端显示信息保持一致
## 🔧 功能优化
### 优化 Telegram 推送消息格式
- 添加价格和数量截位处理:
* 价格保留最多4位小数(截断,不四舍五入)
* 数量保留最多2位小数(截断,不四舍五入)
- 优化账户信息显示格式:
* 有账户名和钱包地址时显示:账户名(0x123...123)
* 只有账户名时显示账户名
* 只有钱包地址时显示脱敏后的地址
* 都没有时显示未知账户
### 配置优化
- 移除 `polygon.rpc.url` 配置,使用 RpcNodeService 统一管理 RPC 节点
- 删除无用的 `position.push.polling-interval``position.push.heartbeat-timeout` 配置项
- 修正日志配置中的包名(polyhermes -> polymarketbot
- 更新 `ApiHealthCheckService` 直接使用 `RpcNodeService.getHttpUrl()`
## 📚 文档更新
- 统一发布说明文件,使用 RELEASE.md 替代版本化文件(RELEASE_v1.0.1.md、RELEASE_v1.1.0.md
- 更新所有部署文档,移除 POLYGON_RPC_URL 相关说明
- 更新所有 Docker Compose 配置文件,移除 POLYGON_RPC_URL 环境变量
- 更新所有部署脚本,移除 POLYGON_RPC_URL 环境变量定义
## 🔧 技术改进
- 重构链上 WebSocket 服务,提取公共代码到 `OnChainWsUtils`
- 创建统一的 WebSocket 连接管理服务 `UnifiedOnChainWsService`
- 添加链上查询市场结算结果的功能(`BlockchainService.getCondition`
- 添加 ABI 编码/解码工具方法(`EthereumUtils.decodeConditionResult`
- 优化代码结构,减少代码重复,提高可维护性
---
# v1.1.0
## 🚀 主要功能
### 🔗 链上 WebSocket 实时监听
- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易
- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件
- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个
- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
- 跟单配置生效/失效时及时更新 WebSocket 订阅
- 使用 Gson 替换所有 JSON 解析,提高解析稳定性
- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复
### 📊 RPC 节点管理
- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点
- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略
- 前端添加启用/禁用开关,支持实时切换节点状态
- 健康检查只检查启用的节点,提高检查效率
- 节点选择时自动过滤禁用的节点
### 💰 卖出订单价格轮询更新
- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新
- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次:
- 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
- 清理已删除账户的订单记录
- 支持加权平均价格计算,处理部分成交的订单
- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新
- 下单完成后不再立即查询价格,直接保存,等待定时任务更新
## 🐛 Bug 修复
### 修复跟单卖出订单的 API 凭证解密问题
- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致
- 确保卖出订单能够正常使用 API 凭证进行认证
### 修复 SELL 订单精度问题
- 修复 SELL 订单的 `makerAmount``takerAmount` 精度问题:
- `makerAmount` (shares) 最多 2 位小数(符合 API 要求)
- `takerAmount` (USDC) 最多 4 位小数(符合 API 要求)
- 确保订单能够正常提交到 Polymarket API
## 📚 文档更新
- 添加 Docker 版本更新说明(中英文)
- 添加链上 WebSocket 监听策略文档
- 添加跟单逻辑总结文档
- 更新部署文档,包含详细的版本更新步骤
## 🔧 技术改进
- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性
- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构
- 优化 WebSocket 连接管理,减少不必要的连接
- 添加线程安全机制,使用 Kotlin Coroutines Mutex
- 启用 Spring 定时任务功能(`@EnableScheduling`
## 📦 数据库变更
- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.1
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.0...v1.1.1
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## 📊 统计信息
- **文件变更**: 32 个文件
- **代码变更**: +1872 行 / -1503 行
- **主要提交**: 7 个提交
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.1
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**https://t.me/polyhermes
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
-177
View File
@@ -1,177 +0,0 @@
# PolyHermes v2.0.0 Release Notes
## 🎉 重大更新
PolyHermes v2.0.0 是一个重要版本更新,带来了系统动态更新功能、优化的用户体验和多项技术改进。
---
## ✨ 新功能
### 🔄 系统动态更新(核心功能)
**无需重启 Docker 容器即可更新系统**,大幅提升部署和维护效率。
-**在线更新**:在 Web UI 中一键检查并应用更新,无需手动操作
-**零停机更新**:更新过程约 30-60 秒,系统自动处理,无需重启容器
-**自动回滚**:更新失败时自动恢复到旧版本,确保系统稳定性
-**版本管理**:清晰显示当前版本和可用更新,支持 Pre-release 版本检测
-**更新内容展示**:支持 Markdown 格式的更新说明,美观易读
**技术特性**
- 独立的 Python Flask 更新服务(端口 9090),与主应用隔离
- 单一更新包(tar.gz),包含前后端完整更新
- 自动备份和版本管理
- 管理员权限验证,确保安全性
### 📦 Release 管理工具
- **自动化发布脚本** (`create-release.sh`)
- 自动创建 Git 标签
- 发布 GitHub Release
- 支持 Pre-release 标记
- 自动拼接版本号后缀(Pre-release 自动添加 `-beta`
- 支持非交互模式,便于 CI/CD 集成
### 🎨 版本号显示优化
- **Tag 格式显示**:版本号使用 Git Tag 格式(如 `v2.0.0-beta`
- **智能颜色提示**
- 🟡 **黄色 Tag**:有新版本可用(点击可跳转到系统更新页面)
- 🟢 **绿色 Tag**:当前已是最新版本
- **镂空样式**:更小巧美观的版本号标签
- **自动检查**:系统自动检查更新,有新版本时在导航栏显示提示
---
## 🎨 UI/UX 优化
### 系统更新页面
- **美化界面**:全新的渐变背景、卡片样式和图标设计
- **Markdown 支持**:更新内容支持完整的 Markdown 渲染(标题、列表、代码块、表格等)
- **进度显示**:美观的进度条和状态提示
- **优化布局**:系统更新模块移至系统设置页面最上方,更易访问
### 版本号显示
- 使用镂空 Tag 样式,字号 8px
- 与标题垂直居中对齐
- 响应式设计,完美支持移动端和桌面端
---
## 🔧 技术改进
### 构建系统
- **修复 Docker 构建问题**
- 修复 `BUILD_IN_DOCKER=false` 时找不到前端产物的问题
- 优化 `.dockerignore` 配置,确保外部构建产物可被使用
- 修复 GitHub Actions 构建流程
- **版本号注入**
- 修复前端构建时版本号未正确传递的问题
- 支持在构建时注入 Git Tag 和版本信息
- **Gradle Wrapper**
- 修复 GitHub Actions 构建错误
- 正确配置 Gradle Wrapper JAR
### 代码质量
- 修复 TypeScript 编译错误
- 清理未使用的导入和组件
- 优化代码结构
---
## 📝 新增文档
- `docs/zh/DYNAMIC_UPDATE.md` - 动态更新技术方案文档
- `docs/zh/DOCKER_VERSION.md` - Docker 版本管理说明
- `docs/zh/DYNAMIC_UPDATE_CHECK.md` - 动态更新检查机制文档
- `scripts/README_RELEASE.md` - Release 脚本使用说明
- `scripts/CHANGELOG_TEMPLATE.md` - 更新日志模板
---
## 🔄 升级指南
### 从 v1.1.16 升级到 v2.0.0
#### 方式一:使用动态更新功能(推荐)
1. 登录系统,进入 **系统设置****系统更新**
2. 点击 **检查更新**
3. 如果有新版本,点击 **立即升级**
4. 等待更新完成(约 30-60 秒)
5. 页面会自动刷新,更新完成
#### 方式二:重新部署 Docker 容器
```bash
# 1. 停止当前容器
docker-compose -f docker-compose.prod.yml down
# 2. 拉取新版本镜像
docker pull wrbug/polyhermes:v2.0.0
# 3. 更新 docker-compose.prod.yml 中的镜像标签
# image: wrbug/polyhermes:v2.0.0
# 4. 重新启动
docker-compose -f docker-compose.prod.yml up -d
```
### 注意事项
- ⚠️ **数据备份**:虽然更新不会删除数据,但建议在更新前备份数据库
- ⚠️ **权限要求**:执行动态更新需要管理员权限
-**向后兼容**v2.0.0 完全兼容 v1.1.16 的数据结构和配置
---
## 📊 变更统计
- **新增文件**17 个
- **修改文件**13 个
- **代码变更**+5251 行,-163 行
### 主要新增文件
- `docker/update-service.py` - 更新服务(Python Flask
- `frontend/src/pages/SystemUpdate.tsx` - 系统更新页面
- `create-release.sh` - Release 创建脚本
- `docs/zh/DYNAMIC_UPDATE.md` - 动态更新技术文档
---
## 🐛 修复的问题
- 修复 Docker 构建时找不到前端产物的问题
- 修复前端构建时版本号未正确传递的问题
- 修复 GitHub Actions 构建错误(Gradle Wrapper
- 修复 TypeScript 编译错误
---
## 📚 相关文档
- [动态更新技术方案](docs/zh/DYNAMIC_UPDATE.md)
- [Docker 版本管理](docs/zh/DOCKER_VERSION.md)
- [部署指南](docs/zh/DEPLOYMENT.md)
- [Release 脚本使用说明](scripts/README_RELEASE.md)
---
## 🙏 致谢
感谢所有使用 PolyHermes 的用户和贡献者!
---
**下载地址**
- Docker Hub: `wrbug/polyhermes:v2.0.0`
- GitHub Releases: [v2.0.0](https://github.com/WrBug/PolyHermes/releases/tag/v2.0.0)
-173
View File
@@ -1,173 +0,0 @@
# PolyHermes v2.0.1 Release Notes
## 📋 版本信息
- **版本号**: v2.0.1
- **发布日期**: 2026-01-28
- **基础版本**: v2.0.0
## 🎯 改动摘要
本次版本主要修复了订单状态检查、RPC 节点管理、订单金额计算等关键问题,提升了系统稳定性和用户体验。
---
## 🐛 Bug 修复
### 1. 修复订单状态检查中缓存清除导致计时重置的问题
**问题描述**
- 订单详情为 `null` 时,缓存清除时机不当导致计时被重置
- 订单超过 60 秒后无法正常删除
- 部分卖出订单等待时间过长(之前等待 1 小时)
**修复内容**
- ✅ 修复缓存清除时机问题,避免计时被重置
- ✅ 统一部分卖出和未成交订单的删除逻辑,都使用 60 秒重试窗口
- ✅ 删除未使用的常量 `PARTIAL_SOLD_CLEANUP_WINDOW_MS`
- ✅ 优化日志输出,区分部分卖出和未成交订单的日志信息
**影响范围**
- `OrderStatusUpdateService.kt` - 订单状态更新服务
**提交**: 7e87965
---
### 2. 修复禁用 RPC 节点后仍被使用的问题
**问题描述**
- 禁用 RPC 节点后,节点仍可能被系统使用
- 节点状态更新不及时
**修复内容**
- ✅ 修复节点禁用逻辑,确保禁用后立即生效
- ✅ 优化节点状态检查机制
- ✅ 改进节点选择逻辑
**影响范围**
- `RpcNodeService.kt` - RPC 节点服务
**提交**: dd39e59
---
### 3. 修复订单金额计算和价格范围验证问题
**问题描述**
- 订单金额计算可能存在精度问题
- 价格范围验证逻辑不完善
**修复内容**
- ✅ 修复订单金额计算逻辑
- ✅ 完善价格范围验证
- ✅ 优化数值计算精度处理
**影响范围**
- `OrderSigningService.kt` - 订单签名服务
**提交**: 2b20f4b
---
### 4. 修复系统更新 API 路由和改进健康检查逻辑
**问题描述**
- 系统更新 API 路由可能存在问题
- 健康检查逻辑需要优化
**修复内容**
- ✅ 修复系统更新 API 路由
- ✅ 改进健康检查逻辑
- ✅ 优化错误处理
**提交**: 4aa85a9
---
### 5. 修复 Docker 构建相关问题
**问题描述**
- 本地 Docker 构建时 `frontend/dist``backend/build` 不存在导致构建失败
- Dockerfile 中前端产物复制逻辑有问题
**修复内容**
- ✅ 修复本地 Docker 构建时目录不存在的问题
- ✅ 修复 Dockerfile 中前端产物复制逻辑
- ✅ 优化构建流程
**影响范围**
- `Dockerfile` - Docker 构建文件
- `deploy.sh` - 部署脚本
**提交**: 419c68c, 8889803
---
### 6. 补充缺失的多语言 key
**问题描述**
- 部分多语言 key 缺失,导致界面显示异常
**修复内容**
- ✅ 补充缺失的多语言 key
- ✅ 完善多语言支持
**影响范围**
- `frontend/src/locales/` - 多语言文件
**提交**: 2efc04a
---
## 🔧 优化改进
### 优化过滤原因文案的数值显示格式
**改进内容**
- ✅ 优化过滤原因文案的数值显示格式
- ✅ 新增日期工具函数,统一日期格式化
- ✅ 提升用户体验
**影响范围**
- `CopyTradingFilterService.kt` - 跟单过滤服务
- `DateUtils.kt` - 日期工具类(新增)
**提交**: e115d45
---
## 📊 文件变更统计
- **修改文件数**: 10+
- **新增文件数**: 1 (DateUtils.kt)
- **新增行数**: 200+
- **删除行数**: 100+
---
## 🔄 升级建议
1. **直接部署**:无需特殊操作,直接部署即可
2. **验证订单处理**:建议验证订单状态检查是否正常工作
3. **检查 RPC 节点**:确认 RPC 节点状态管理是否正常
4. **验证订单金额**:确认订单金额计算是否正确
---
## 📝 完整提交列表
- 7e87965 - fix: 修复订单状态检查中缓存清除导致计时重置的问题
- e115d45 - refactor: 优化过滤原因文案的数值显示格式
- dd39e59 - fix: 修复禁用RPC节点后仍被使用的问题
- 2efc04a - fix: 补充缺失的多语言key
- 2b20f4b - fix: 修复订单金额计算和价格范围验证问题
- 419c68c - fix: 修复本地 Docker 构建时 frontend/dist 和 backend/build 不存在的问题
- 8889803 - fix: 修复 Dockerfile 中前端产物复制逻辑
- 4aa85a9 - fix: 修复系统更新 API 路由和改进健康检查逻辑
---
## 🙏 致谢
感谢所有贡献者和用户的支持与反馈!
-144
View File
@@ -1,144 +0,0 @@
# PolyHermes v2.0.2 Release Notes
## 📋 版本信息
- **版本号**: v2.0.2
- **发布日期**: 2026-01-29
- **基础版本**: v2.0.1
## 🎯 改动摘要
本次版本主要修复了买入订单金额精度问题,并优化了构建流程和通知机制。
---
## 🐛 Bug 修复
### 1. 修复买入订单金额精度问题
**问题描述**
- 市场买入订单创建时出现 `invalid amounts` 错误
- Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
- 之前的实现不符合 API 要求,导致订单创建失败
**修复内容**
- ✅ 修复市场买入订单 makerAmount 和 takerAmount 的精度限制
- ✅ makerAmount (USDC) 限制为最多 2 位小数(之前为 4 位)
- ✅ takerAmount (shares) 限制为最多 4 位小数(之前为 2 位)
- ✅ 符合 Polymarket API 的要求,解决 'invalid amounts' 错误
**影响范围**
- `OrderSigningService.kt` - 订单签名服务
**提交**: 42472f6
---
## 🔧 优化改进
### 优化构建流程和通知机制
**改进内容**
- ✅ 添加 workflow_dispatch 支持手动触发构建
- ✅ Release 事件默认只打包产物(在线升级),不构建 Docker
- ✅ 手动触发可选择构建类型:在线升级或 Docker 升级
- ✅ 优化 Telegram 通知文案,区分构建类型和升级路径
- ✅ 在线升级任务不发送开始通知,只发送完成通知
- ✅ 手动触发时产物上传为 ArtifactRelease 事件上传到 Release Assets
**影响范围**
- `.github/workflows/docker-build.yml` - GitHub Actions 工作流
**提交**: e5992b5
---
## ⚠️ 潜在问题和注意事项
### 1. 买入订单金额精度调整可能带来的影响
**问题说明**
本次修复调整了买入订单的金额精度限制,可能会带来以下影响:
#### 1.1 makerAmount 精度降低(4 位 → 2 位小数)
**影响**
- **订单金额可能被向下舍入**:由于 makerAmount (USDC) 从 4 位小数降低到 2 位小数,订单金额可能会被向下舍入
- **实际支付金额可能略低于预期**:例如,如果计算出的金额是 `0.5985 USDC`,现在会被舍入为 `0.59 USDC`,实际支付金额可能比预期少 `0.0085 USDC`
- **可能影响固定金额模式的跟单**:在固定金额模式下,如果金额被舍入,实际买入的数量可能会略少于预期
**建议**
- 在固定金额模式下,建议设置稍大一点的金额,以补偿可能的舍入损失
- 监控订单创建情况,确认金额是否符合预期
- 如果发现金额差异较大,可以适当调整跟单配置
#### 1.2 takerAmount 精度提高(2 位 → 4 位小数)
**影响**
- **订单数量精度提高**takerAmount (shares) 从 2 位小数提高到 4 位小数,可以更精确地指定买入数量
- **可能增加订单复杂度**:更高的精度可能导致一些边缘情况,需要确保数量计算正确
**建议**
- 验证订单数量是否符合预期
- 确认跟单数量计算逻辑是否正确
#### 1.3 订单创建成功率变化
**影响**
- **修复前**:订单创建可能因为精度问题失败
- **修复后**:订单创建成功率应该提高,但金额可能略有变化
**建议**
- 升级后监控订单创建成功率
- 对比升级前后的订单金额,确认差异是否在可接受范围内
### 2. 构建流程变更
**影响**
- Release 事件默认只打包产物,不构建 Docker
- 如果需要 Docker 镜像,需要手动触发构建并选择 Docker 升级类型
**建议**
- 了解新的构建流程,根据需要选择合适的构建方式
- 如果需要 Docker 镜像,使用 workflow_dispatch 手动触发
---
## 📊 文件变更统计
- **修改文件数**: 2
- **新增行数**: 127
- **删除行数**: 27
---
## 🔄 升级建议
1. **测试买入订单创建**
- 升级后先测试少量买入订单,确认金额和数量是否符合预期
- 特别关注固定金额模式的跟单,确认金额是否被正确舍入
2. **监控订单创建成功率**
- 升级后监控订单创建成功率,确认是否解决了 `invalid amounts` 错误
- 对比升级前后的订单金额,确认差异是否在可接受范围内
3. **验证金额计算**
- 验证 makerAmount 是否被正确限制为 2 位小数
- 验证 takerAmount 是否被正确限制为 4 位小数
4. **了解构建流程变更**
- 了解新的构建流程,根据需要选择合适的构建方式
- 如果需要 Docker 镜像,使用 workflow_dispatch 手动触发
---
## 📝 完整提交列表
- 42472f6 - fix: 修复买入订单金额精度问题
- e5992b5 - feat(workflow): 优化构建流程和通知机制
---
## 🙏 致谢
感谢所有贡献者和用户的支持与反馈!
-241
View File
@@ -1,241 +0,0 @@
# PolyHermes v2.0.3 Release Notes
## 📋 版本信息
- **版本号**: v2.0.3
- **发布日期**: 2026-01-31
- **基础版本**: v2.0.2
## 🎯 改动摘要
本次版本主要优化了用户界面显示,包括数值格式化、Leader列表优化、移除不必要的配置项,提升了用户体验。
---
## ✨ 新功能
### 1. 为所有数值显示添加千分位分隔符
**功能描述**
- ✅ 重构 `formatNumber``formatUSDC` 函数,默认添加千分位分隔符
- ✅ 所有数值(金额、数量、价格等)现在默认显示千分位
- ✅ 自动去除尾随零,提升可读性
- ✅ 示例:`1234567.89` 显示为 `1,234,567.89`
**影响范围**
- `frontend/src/utils/index.ts` - 工具函数
- `frontend/src/pages/Statistics.tsx` - 统计页面
- `frontend/src/pages/CopyTradingStatistics.tsx` - 跟单统计页面
- `frontend/src/pages/PositionList.tsx` - 持仓列表
- `frontend/src/pages/AccountList.tsx` - 账户列表
**提交**: 40081c2
---
### 2. 在创建跟单配置时显示Leader资产信息
**功能描述**
- ✅ 选择Leader后自动获取并显示资产信息
- ✅ 显示总资产、可用余额、仓位资产
- ✅ 使用Card和Statistic组件美观展示
- ✅ 支持中英文多语言
- ✅ 使用formatUSDC格式化显示金额
**影响范围**
- `frontend/src/pages/CopyTradingOrders/AddModal.tsx` - 添加跟单配置弹窗
- `frontend/src/pages/CopyTradingOrders/EditModal.tsx` - 编辑跟单配置弹窗
- `frontend/src/locales/**/common.json` - 多语言文件
**提交**: 390b3ee
---
### 3. Leader列表显示仓位资产
**功能描述**
- ✅ Leader列表新增仓位资产显示
- ✅ 显示Leader的总资产、可用余额、仓位资产
- ✅ 优化资产信息展示方式
**影响范围**
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
- `backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt` - Leader DTO
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt` - Leader服务
**提交**: 3350039
---
## 🔧 优化改进
### 1. Leader列表优化
**改进内容**
- ✅ 后端过滤价值为0的仓位
- ✅ 持仓列表显示市场名称而非ID
- ✅ 列表移除分类和创建时间列
- ✅ 文案'跟单关系数'改为'跟单数'
- ✅ 持仓DTO添加title字段
**影响范围**
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt` - Leader服务
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt` - 区块链服务
**提交**: 0bdc0c7
---
### 2. 列表只显示可用余额
**改进内容**
- ✅ 账户列表和Leader列表只显示可用余额
- ✅ 简化界面,减少信息冗余
**影响范围**
- `frontend/src/pages/AccountList.tsx` - 账户列表
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt` - 账户服务
**提交**: 17eea01
---
### 3. 移除仓位资产列
**改进内容**
- ✅ 移除不必要的仓位资产列显示
- ✅ 简化界面布局
**影响范围**
- `frontend/src/pages/PositionList.tsx` - 持仓列表
**提交**: 6980781
---
## 🗑️ 移除功能
### 移除跟单最大仓位数量(maxPositionCount)配置
**移除原因**
- 该配置项使用频率低,且增加了系统复杂度
- 简化跟单配置,提升用户体验
**移除内容**
- ✅ 数据库:创建迁移文件 V26 删除 `max_position_count` 字段
- ✅ 后端:移除实体类、DTO、服务中的 `maxPositionCount` 相关代码
- ✅ 后端:移除 `FilterResult` 中的 `FAILED_MAX_POSITION_COUNT` 状态
- ✅ 后端:移除 `CopyTradingFilterService` 中的最大仓位数量检查逻辑
- ✅ 前端:移除类型定义、表单字段和国际化翻译
- ✅ 前端:移除过滤订单列表中的 `MAX_POSITION_COUNT` 类型
**影响范围**
- `backend/src/main/resources/db/migration/V26__remove_max_position_count.sql` - 数据库迁移
- `backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt` - 实体类
- `backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt` - DTO
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt` - 过滤服务
- `frontend/src/pages/CopyTradingOrders/AddModal.tsx` - 添加表单
- `frontend/src/pages/CopyTradingOrders/EditModal.tsx` - 编辑表单
- `frontend/src/types/index.ts` - 类型定义
**提交**: e8fd1b5
---
## 🐛 Bug 修复
### 修复TypeScript类型错误
**修复内容**
- ✅ 修复编译时的TypeScript类型错误
- ✅ 修复Spin导入问题
- ✅ 修复Table fixed类型问题
- ✅ 修复size类型问题
**影响范围**
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
**提交**: 8097660
---
## ⚠️ 潜在问题和注意事项
### 1. 数值格式化变更
**影响**
- 所有数值现在默认显示千分位分隔符
- 如果之前有代码依赖特定的数值格式,可能需要调整
**建议**
- 检查是否有代码依赖特定的数值格式
- 确认数值显示是否符合预期
### 2. 移除maxPositionCount配置
**影响**
- 如果之前使用了最大仓位数量限制功能,升级后将不再可用
- 需要手动调整跟单策略
**建议**
- 升级前检查是否有跟单配置使用了最大仓位数量限制
- 如有需要,可以手动调整跟单策略
### 3. Leader列表显示变更
**影响**
- Leader列表现在只显示价值大于0的仓位
- 列表布局和显示内容有所调整
**建议**
- 升级后检查Leader列表显示是否符合预期
- 确认仓位信息是否正确显示
---
## 📊 文件变更统计
- **修改文件数**: 28
- **新增行数**: 1490
- **删除行数**: 897
---
## 🔄 升级建议
1. **检查数值显示**
- 升级后检查所有数值显示是否符合预期
- 确认千分位分隔符显示正确
2. **检查跟单配置**
- 如果有使用最大仓位数量限制的配置,需要手动调整
- 确认跟单功能正常工作
3. **检查Leader列表**
- 升级后检查Leader列表显示是否正确
- 确认仓位信息是否完整
4. **数据库迁移**
- 升级时会自动执行数据库迁移 V26
- 迁移会删除 `max_position_count` 字段
- 建议在升级前备份数据库
---
## 📝 完整提交列表
- 40081c2 - feat: 为所有数值显示添加千分位分隔符
- e8fd1b5 - 移除跟单最大仓位数量(maxPositionCount)配置
- 390b3ee - feat: 在创建跟单配置时显示Leader资产信息
- 8097660 - fix: 修复TypeScript类型错误
- 17eea01 - refactor: 列表只显示可用余额
- 6980781 - refactor: 移除仓位资产列
- 3350039 - feat: Leader列表显示仓位资产
- 0bdc0c7 - feat: Leader列表优化
---
## 🙏 致谢
感谢所有贡献者和用户的支持与反馈!
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# 清理 Flyway V29 失败记录的脚本
echo "=== 清理 Flyway V29 失败记录 ==="
echo ""
echo "请确保 MySQL 正在运行,然后输入数据库密码"
echo ""
# 数据库配置
DB_HOST="localhost"
DB_PORT="3306"
DB_NAME="polymarket_bot"
DB_USER="root"
# 检查 MySQL 命令是否可用
if ! command -v mysql &> /dev/null; then
echo "❌ 错误: 未找到 mysql 命令"
echo ""
echo "请使用数据库客户端(如 Navicat、DataGrip 等)执行以下 SQL"
echo ""
echo "-- 1. 查看 Flyway 历史记录"
echo "SELECT version, description, installed_on, success "
echo "FROM flyway_schema_history "
echo "WHERE version >= 28"
echo "ORDER BY installed_rank;"
echo ""
echo "-- 2. 删除 V29 的失败记录"
echo "DELETE FROM flyway_schema_history WHERE version = '29';"
echo ""
exit 1
fi
# 执行清理
echo "正在连接数据库..."
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p "$DB_NAME" << 'EOF'
-- 查看当前状态
SELECT '=== 当前 Flyway 历史记录 ===' as '';
SELECT version, description, installed_on, success
FROM flyway_schema_history
WHERE version >= 28
ORDER BY installed_rank;
-- 删除 V29 失败记录
SELECT '=== 删除 V29 记录 ===' as '';
DELETE FROM flyway_schema_history WHERE version = '29';
-- 确认删除结果
SELECT CONCAT('已删除 ', ROW_COUNT(), ' 条记录') as result;
-- 再次查看状态
SELECT '=== 清理后的 Flyway 历史记录 ===' as '';
SELECT version, description, installed_on, success
FROM flyway_schema_history
WHERE version >= 28
ORDER BY installed_rank;
EOF
echo ""
echo "✅ 清理完成!现在可以重启应用了"
@@ -41,6 +41,17 @@ interface BuilderRelayerApi {
@Query("address") address: String,
@Query("type") type: String
): Response<NoncePayload>
/**
* 获取 Relay PayloadPROXY 类型执行时使用)
* GET /relay-payload?address={address}&type=PROXY
* 参考: builder-relayer-client endpoints GET_RELAY_PAYLOAD
*/
@GET("/relay-payload")
suspend fun getRelayPayload(
@Query("address") address: String,
@Query("type") type: String
): Response<RelayPayload>
/**
* 获取交易状态
@@ -96,6 +107,7 @@ interface BuilderRelayerApi {
/**
* 签名参数
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
* Safe 使用 operation/safeTxnGas/baseGas 等,PROXY 使用 relayHub/relay/relayerFee 等
*/
data class SignatureParams(
@SerializedName("gasPrice")
@@ -114,7 +126,19 @@ interface BuilderRelayerApi {
val gasToken: String? = null,
@SerializedName("refundReceiver")
val refundReceiver: String? = null
val refundReceiver: String? = null,
@SerializedName("relayerFee")
val relayerFee: String? = null,
@SerializedName("gasLimit")
val gasLimit: String? = null,
@SerializedName("relayHub")
val relayHub: String? = null,
@SerializedName("relay")
val relay: String? = null
)
/**
@@ -142,6 +166,17 @@ interface BuilderRelayerApi {
@SerializedName("nonce")
val nonce: String
)
/**
* Relay PayloadPROXY 执行时获取 relay 地址与 nonce
* 参考: builder-relayer-client types RelayPayload
*/
data class RelayPayload(
@SerializedName("address")
val address: String,
@SerializedName("nonce")
val nonce: String
)
/**
* Relayer 交易详情
@@ -23,6 +23,50 @@ class AccountController(
private val logger = LoggerFactory.getLogger(AccountController::class.java)
/**
* 检查代理地址选项(用于导入前选择代理类型)
*/
@PostMapping("/check-proxy-options")
fun checkProxyOptions(@RequestBody request: CheckProxyOptionsRequest): ResponseEntity<ApiResponse<CheckProxyOptionsResponse>> {
return try {
if (request.walletAddress.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_WALLET_ADDRESS_EMPTY, messageSource = messageSource))
}
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "必须提供私钥或助记词", messageSource))
}
val result = runBlocking { accountService.checkProxyOptions(request) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("检查代理地址选项失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
)
else -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_ERROR,
e.message,
messageSource
)
)
}
}
)
} catch (e: Exception) {
logger.error("检查代理地址选项异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 通过私钥导入账户
*/
@@ -45,14 +89,17 @@ class AccountController(
onFailure = { e ->
logger.error("导入账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
} else {
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
)
)
}
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
}
}
@@ -0,0 +1,256 @@
package com.wrbug.polymarketbot.controller.backtest
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.backtest.BacktestService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* 回测管理控制器
*/
@RestController
@RequestMapping("/api/backtest")
class BacktestController(
private val backtestService: BacktestService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(BacktestController::class.java)
/**
* 创建回测任务
*/
@PostMapping("/tasks")
fun createBacktestTask(@RequestBody request: BacktestCreateRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
return try {
logger.info("创建回测任务: taskName=${request.taskName}, leaderId=${request.leaderId}")
val result = runBlocking {
backtestService.createBacktestTask(request)
}
result.fold(
onSuccess = { dto ->
logger.info("回测任务创建成功: taskId=${dto.id}")
ResponseEntity.ok(ApiResponse.success(dto))
},
onFailure = { e ->
logger.error("创建回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.PARAM_ERROR
else -> ErrorCode.SERVER_BACKTEST_CREATE_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("创建回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_CREATE_FAILED, e.message, messageSource))
}
}
/**
* 查询回测任务列表
*/
@PostMapping("/tasks/list")
fun getBacktestTaskList(@RequestBody request: BacktestListRequest): ResponseEntity<ApiResponse<BacktestListResponse>> {
return try {
val result = backtestService.getBacktestTaskList(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测任务列表成功: total=${response.total}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测任务列表失败", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测任务列表异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询回测任务详情
*/
@PostMapping("/tasks/detail")
fun getBacktestTaskDetail(@RequestBody request: BacktestDetailRequest): ResponseEntity<ApiResponse<BacktestDetailResponse>> {
return try {
val result = backtestService.getBacktestTaskDetail(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测任务详情成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测任务详情失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
else -> ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测任务详情异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询回测交易记录
*/
@PostMapping("/tasks/trades")
fun getBacktestTrades(@RequestBody request: BacktestTradeListRequest): ResponseEntity<ApiResponse<BacktestTradeListResponse>> {
return try {
val result = backtestService.getBacktestTrades(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测交易记录成功: taskId=${request.taskId}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测交易记录失败", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测交易记录异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 删除回测任务
*/
@PostMapping("/tasks/delete")
fun deleteBacktestTask(@RequestBody request: BacktestDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("删除回测任务: taskId=${request.id}")
val result = backtestService.deleteBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务删除成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("删除回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_DELETE_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("删除回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DELETE_FAILED, e.message, messageSource))
}
}
/**
* 停止回测任务
*/
@PostMapping("/tasks/stop")
fun stopBacktestTask(@RequestBody request: BacktestStopRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("停止回测任务: taskId=${request.id}")
val result = backtestService.stopBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务停止成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("停止回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_STOP_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("停止回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_STOP_FAILED, e.message, messageSource))
}
}
/**
* 重试回测任务
*/
@PostMapping("/tasks/retry")
fun retryBacktestTask(@RequestBody request: BacktestRetryRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("重试回测任务: taskId=${request.id}")
val result = backtestService.retryBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务重试成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("重试回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_RETRY_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("重试回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RETRY_FAILED, e.message, messageSource))
}
}
/**
* 按当前配置重新测试:基于已完成的回测任务创建相同配置的新任务(仅支持已完成任务)
*/
@PostMapping("/tasks/rerun")
fun rerunBacktestTask(@RequestBody request: BacktestRerunRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
return try {
logger.info("按配置重新测试: sourceTaskId=${request.id}, newTaskName=${request.taskName}")
val result = backtestService.rerunBacktestTask(request)
result.fold(
onSuccess = { dto ->
logger.info("重新测试任务创建成功: newTaskId=${dto.id}")
ResponseEntity.ok(ApiResponse.success(dto))
},
onFailure = { e ->
logger.error("按配置重新测试失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_NOT_COMPLETED
else -> ErrorCode.SERVER_BACKTEST_RERUN_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("按配置重新测试异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RERUN_FAILED, e.message, messageSource))
}
}
}
@@ -11,6 +11,37 @@ data class AccountImportRequest(
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
)
/**
* 检查代理地址选项请求
*/
data class CheckProxyOptionsRequest(
val walletAddress: String, // EOA 地址(必需)
val privateKey: String? = null, // 私钥(加密,私钥导入时提供)
val mnemonic: String? = null // 助记词(加密,助记词导入时提供)
)
/**
* 代理地址选项信息
*/
data class ProxyOptionDto(
val walletType: String, // "magic" 或 "safe"
val proxyAddress: String, // 代理地址
val descriptionKey: String, // 说明文案的多语言 key(如 "accountImport.proxyOption.magic.description"
val availableBalance: String, // 可用余额
val positionBalance: String, // 仓位余额
val totalBalance: String, // 总余额
val positionCount: Int, // 持仓数量
val hasAssets: Boolean, // 是否有资产(余额>0 或持仓>0
val error: String? = null // 获取失败时的错误信息(可选)
)
/**
* 检查代理地址选项响应
*/
data class CheckProxyOptionsResponse(
val options: List<ProxyOptionDto> // 代理地址选项列表(私钥导入返回2个,助记词返回1个)
)
/**
* 账户更新请求
*/
@@ -0,0 +1,211 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
/**
* 回测任务创建请求
*/
data class BacktestCreateRequest(
val taskName: String, // 回测任务名称
val leaderId: Long, // Leader ID
val initialBalance: String, // 初始资金
val backtestDays: Int, // 回测天数 (1-30)
// 跟单配置(与 CopyTrading 一致,但不包含 max_position_count
val copyMode: String? = null, // "RATIO" 或 "FIXED"
val copyRatio: String? = null, // 仅在 copyMode="RATIO" 时生效
val fixedAmount: String? = null, // 仅在 copyMode="FIXED" 时生效
val maxOrderSize: String? = null,
val minOrderSize: String? = null,
val maxDailyLoss: String? = null,
val maxDailyOrders: Int? = null,
val supportSell: Boolean? = null,
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
)
/**
* 回测任务列表请求
*/
data class BacktestListRequest(
val leaderId: Long? = null, // Leader ID(可选)
val status: String? = null, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
val sortBy: String? = null, // profitAmount / profitRate / createdAt
val sortOrder: String? = null, // asc / desc
val page: Int = 1, // 页码,从1开始
val size: Int = 20 // 每页数量
)
/**
* 回测任务详情请求
*/
data class BacktestDetailRequest(
val id: Long // 回测任务ID
)
/**
* 回测交易记录请求
*/
data class BacktestTradeListRequest(
val taskId: Long, // 回测任务ID
val page: Int = 1, // 页码,从1开始
val size: Int = 20 // 每页数量
)
/**
* 回测进度查询请求
*/
data class BacktestProgressRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务停止请求
*/
data class BacktestStopRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务删除请求
*/
data class BacktestDeleteRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务重试请求
*/
data class BacktestRetryRequest(
val id: Long // 回测任务ID
)
/**
* 按当前配置重新测试请求(仅支持已完成任务)
*/
data class BacktestRerunRequest(
val id: Long, // 源回测任务ID
val taskName: String? = null // 新任务名称,为空时使用「原名称 (副本)」
)
/**
* 回测任务列表响应
*/
data class BacktestListResponse(
val list: List<BacktestTaskDto>,
val total: Long,
val page: Int,
val size: Int
)
/**
* 回测任务详情响应
*/
data class BacktestDetailResponse(
val task: BacktestTaskDto,
val config: BacktestConfigDto,
val statistics: BacktestStatisticsDto
)
/**
* 回测交易记录列表响应
*/
data class BacktestTradeListResponse(
val list: List<BacktestTradeDto>,
val total: Long,
val page: Int,
val size: Int
)
/**
* 回测进度响应
*/
data class BacktestProgressResponse(
val progress: Int, // 执行进度 (0-100)
val currentBalance: String, // 当前余额
val totalTrades: Int, // 总交易笔数
val status: String // 任务状态
)
/**
* 回测任务 DTO
*/
data class BacktestTaskDto(
val id: Long,
val taskName: String,
val leaderId: Long,
val leaderName: String?,
val leaderAddress: String?,
val initialBalance: String,
val finalBalance: String?,
val profitAmount: String?,
val profitRate: String?,
val backtestDays: Int,
val startTime: Long,
val endTime: Long?,
val status: String, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
val progress: Int,
val totalTrades: Int,
val createdAt: Long,
val executionStartedAt: Long?,
val executionFinishedAt: Long?
)
/**
* 回测配置 DTO
*/
data class BacktestConfigDto(
val copyMode: String,
val copyRatio: String,
val fixedAmount: String?,
val maxOrderSize: String,
val minOrderSize: String,
val maxDailyLoss: String,
val maxDailyOrders: Int,
val supportSell: Boolean,
val keywordFilterMode: String?,
val keywords: List<String>?,
val maxPositionValue: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? // 最高价格(可选),NULL表示不限制最高价
)
/**
* 回测统计信息 DTO
*/
data class BacktestStatisticsDto(
val totalTrades: Int, // 总交易笔数
val buyTrades: Int, // 买入笔数
val sellTrades: Int, // 卖出笔数
val winTrades: Int, // 盈利交易笔数
val lossTrades: Int, // 亏损交易笔数
val winRate: String, // 胜率(%)
val maxProfit: String, // 最大单笔盈利
val maxLoss: String, // 最大单笔亏损
val maxDrawdown: String, // 最大回撤
val avgHoldingTime: Long? // 平均持仓时间(毫秒)
)
/**
* 回测交易记录 DTO
*/
data class BacktestTradeDto(
val id: Long,
val tradeTime: Long,
val marketId: String,
val marketTitle: String?,
val side: String, // BUY/SELL/SETTLEMENT
val outcome: String,
val outcomeIndex: Int?,
val quantity: String,
val price: String,
val amount: String,
val fee: String,
val profitLoss: String?,
val balanceAfter: String,
val leaderTradeId: String?
)
@@ -54,6 +54,7 @@ data class LeaderDto(
val remark: String? = null, // Leader 备注(可选)
val website: String? = null, // Leader 网站(可选)
val copyTradingCount: Long = 0, // 跟单关系数量
val backtestCount: Long = 0, // 回测数量
val totalOrders: Long? = null, // 总订单数(可选)
val totalPnl: String? = null, // 总盈亏(可选)
val createdAt: Long,
@@ -0,0 +1,32 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
/**
* 用户交易数据
* 用于回测功能,从 Polymarket API 获取的用户交易历史
*/
data class TradeData(
val tradeId: String, // 交易 ID
val marketId: String, // 市场 ID
val marketTitle: String?, // 市场标题
val marketSlug: String?, // 市场 Slug
val side: String, // 交易方向: BUY/SELL
val outcome: String, // 结果: YES/NO 或 outcomeIndex
val outcomeIndex: Int?, // 结果索引
val price: BigDecimal, // 成交价格
val size: BigDecimal, // 成交数量
val amount: BigDecimal, // 成交金额
val timestamp: Long // 交易时间戳
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is TradeData) return false
return tradeId == other.tradeId
}
override fun hashCode(): Int {
return tradeId.hashCode()
}
}
@@ -16,11 +16,11 @@ data class Account(
@Column(name = "private_key", nullable = false, length = 500)
val privateKey: String, // 私钥(AES 加密存储)
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导)
@Column(name = "wallet_address", nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导),同一 EOA 可有多个账户(不同代理类型)
@Column(name = "proxy_address", nullable = false, length = 42)
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
@Column(name = "proxy_address", unique = true, nullable = false, length = 42)
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须),唯一
@Column(name = "api_key", length = 500)
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
@@ -0,0 +1,151 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import com.wrbug.polymarketbot.util.toSafeBigDecimal
/**
* 回测任务实体
*/
@Entity
@Table(name = "backtest_task")
data class BacktestTask(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "task_name", nullable = false, length = 100)
val taskName: String,
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
// 回测参数
@Column(name = "initial_balance", nullable = false, precision = 20, scale = 8)
val initialBalance: BigDecimal,
@Column(name = "final_balance", precision = 20, scale = 8)
var finalBalance: BigDecimal? = null,
@Column(name = "profit_amount", precision = 20, scale = 8)
var profitAmount: BigDecimal? = null,
@Column(name = "profit_rate", precision = 10, scale = 4)
var profitRate: BigDecimal? = null, // 收益率(%)
@Column(name = "backtest_days", nullable = false)
val backtestDays: Int,
@Column(name = "start_time", nullable = false)
val startTime: Long, // 回测开始时间(历史时间),创建时计算;执行时以当前时间为基准用局部变量重算窗口
@Column(name = "end_time")
var endTime: Long? = null, // 回测结束时间(历史时间)
// 跟单配置 (复制CopyTrading表结构,但不包含 max_position_count)
@Column(name = "copy_mode", nullable = false, length = 10)
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
val copyRatio: BigDecimal = BigDecimal.ONE,
@Column(name = "fixed_amount", precision = 20, scale = 8)
val fixedAmount: BigDecimal? = null,
@Column(name = "max_order_size", nullable = false, precision = 20, scale = 8)
val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(),
@Column(name = "min_order_size", nullable = false, precision = 20, scale = 8)
val minOrderSize: BigDecimal = "1".toSafeBigDecimal(),
@Column(name = "max_daily_loss", nullable = false, precision = 20, scale = 8)
val maxDailyLoss: BigDecimal = "10000".toSafeBigDecimal(),
@Column(name = "max_daily_orders", nullable = false)
val maxDailyOrders: Int = 100,
@Column(name = "support_sell", nullable = false)
val supportSell: Boolean = true,
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
val keywordFilterMode: String = "DISABLED", // DISABLED/WHITELIST/BLACKLIST
@Column(name = "keywords", columnDefinition = "JSON")
val keywords: String? = null,
@Column(name = "max_position_value", precision = 20, scale = 8)
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
@Column(name = "min_price", precision = 20, scale = 8)
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
@Column(name = "max_price", precision = 20, scale = 8)
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
// 统计字段
@Column(name = "avg_holding_time")
var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒)
@Column(name = "data_source", length = 50)
var dataSource: String = "MIXED", // INTERNAL/API/MIXED
// 执行状态
@Column(name = "status", nullable = false, length = 20)
var status: String = "PENDING", // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
@Column(name = "progress", nullable = false)
var progress: Int = 0, // 执行进度(0-100)
@Column(name = "total_trades", nullable = false)
var totalTrades: Int = 0,
@Column(name = "buy_trades", nullable = false)
var buyTrades: Int = 0,
@Column(name = "sell_trades", nullable = false)
var sellTrades: Int = 0,
@Column(name = "win_trades", nullable = false)
var winTrades: Int = 0,
@Column(name = "loss_trades", nullable = false)
var lossTrades: Int = 0,
@Column(name = "win_rate", precision = 5, scale = 2)
var winRate: BigDecimal? = null, // 胜率(%)
@Column(name = "max_profit", precision = 20, scale = 8)
var maxProfit: BigDecimal? = null, // 最大单笔盈利
@Column(name = "max_loss", precision = 20, scale = 8)
var maxLoss: BigDecimal? = null, // 最大单笔亏损
@Column(name = "max_drawdown", precision = 20, scale = 8)
var maxDrawdown: BigDecimal? = null, // 最大回撤
@Column(name = "error_message", columnDefinition = "TEXT")
var errorMessage: String? = null,
// 时间字段
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "execution_started_at")
var executionStartedAt: Long? = null,
@Column(name = "execution_finished_at")
var executionFinishedAt: Long? = null,
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis(),
@Column(name = "last_processed_trade_time")
var lastProcessedTradeTime: Long? = null,
@Column(name = "last_processed_trade_index")
var lastProcessedTradeIndex: Int? = null,
@Column(name = "processed_trade_count")
var processedTradeCount: Int = 0
)
@@ -0,0 +1,62 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* 回测交易记录实体
* 用于记录回测过程中的每笔模拟交易
*/
@Entity
@Table(name = "backtest_trade")
data class BacktestTrade(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "backtest_task_id", nullable = false)
val backtestTaskId: Long,
@Column(name = "trade_time", nullable = false)
val tradeTime: Long,
@Column(name = "market_id", nullable = false, length = 100)
val marketId: String,
@Column(name = "market_title", length = 500)
val marketTitle: String? = null,
@Column(name = "side", nullable = false, length = 20)
val side: String, // BUY/SELL/SETTLEMENT
@Column(name = "outcome", nullable = false, length = 50)
val outcome: String, // YES/NO 或 outcomeIndex
@Column(name = "outcome_index")
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
val quantity: BigDecimal,
@Column(name = "price", nullable = false, precision = 20, scale = 8)
val price: BigDecimal,
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
val amount: BigDecimal,
@Column(name = "fee", nullable = false, precision = 20, scale = 8)
val fee: BigDecimal = BigDecimal.ZERO, // 手续费(回测不计算,默认为0
@Column(name = "profit_loss", precision = 20, scale = 8)
val profitLoss: BigDecimal? = null, // 盈亏(仅卖出时)
@Column(name = "balance_after", nullable = false, precision = 20, scale = 8)
val balanceAfter: BigDecimal, // 交易后余额
@Column(name = "leader_trade_id", length = 100)
val leaderTradeId: String? = null, // Leader 原始交易ID
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@@ -231,7 +231,26 @@ enum class ErrorCode(
SERVER_ORDER_TRACKING_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"),
SERVER_ORDER_TRACKING_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_failed"),
SERVER_ORDER_TRACKING_SELL_FAILED(5903, "处理卖出订单失败", "error.server.order_tracking_sell_failed"),
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed");
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed"),
// 回测服务错误 (4601-4699)
BACKTEST_TASK_NOT_FOUND(4601, "回测任务不存在", "error.backtest.task_not_found"),
BACKTEST_LEADER_NOT_FOUND(4602, "Leader不存在", "error.backtest.leader_not_found"),
BACKTEST_DAYS_INVALID(4603, "回测天数超出限制", "error.backtest.days_invalid"),
BACKTEST_INITIAL_BALANCE_INVALID(4604, "初始金额无效", "error.backtest.initial_balance_invalid"),
BACKTEST_TASK_RUNNING(4605, "回测任务正在运行,无法删除", "error.backtest.task_running"),
BACKTEST_TASK_NOT_COMPLETED(4606, "仅支持对已完成的回测任务重新测试", "error.backtest.task_not_completed"),
SERVER_BACKTEST_CREATE_FAILED(5603, "创建回测任务失败", "error.server.backtest_create_failed"),
SERVER_BACKTEST_UPDATE_FAILED(5604, "更新回测任务失败", "error.server.backtest_update_failed"),
SERVER_BACKTEST_DELETE_FAILED(5605, "删除回测任务失败", "error.server.backtest_delete_failed"),
SERVER_BACKTEST_LIST_FETCH_FAILED(5606, "查询回测列表失败", "error.server.backtest_list_fetch_failed"),
SERVER_BACKTEST_DETAIL_FETCH_FAILED(5607, "查询回测详情失败", "error.server.backtest_detail_fetch_failed"),
SERVER_BACKTEST_TRADES_FETCH_FAILED(5608, "查询回测交易记录失败", "error.server.backtest_trades_fetch_failed"),
SERVER_BACKTEST_EXECUTE_FAILED(5609, "回测执行失败", "error.server.backtest_execute_failed"),
SERVER_BACKTEST_HISTORICAL_DATA_FETCH_FAILED(5610, "历史数据获取失败", "error.server.backtest_historical_data_fetch_failed"),
SERVER_BACKTEST_STOP_FAILED(5611, "停止回测任务失败", "error.server.backtest_stop_failed"),
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed");
companion object {
/**
@@ -0,0 +1,51 @@
package com.wrbug.polymarketbot.enums
/**
* 钱包类型枚举
*/
enum class WalletType(val value: String, val description: String) {
/**
* Magic 钱包(邮箱/OAuth 登录)
* 使用 PROXY 代理合约,通过 Builder Relayer 执行 Gasless 交易
*/
MAGIC("magic", "Magic(邮箱/OAuth登录)"),
/**
* Safe 钱包(MetaMask 等 Web3 钱包)
* 使用 Gnosis Safe 代理合约,支持 Builder Relayer Gasless 或手动交易
*/
SAFE("safe", "SafeWeb3钱包)");
companion object {
/**
* 从字符串值解析钱包类型(不区分大小写)
*/
fun fromString(value: String?): WalletType {
if (value.isNullOrBlank()) {
return SAFE // 默认返回 SAFE
}
return values().find { it.value.equals(value, ignoreCase = true) }
?: throw IllegalArgumentException("未知的钱包类型: $value")
}
/**
* 安全地从字符串值解析钱包类型(不区分大小写),解析失败返回默认值
*/
fun fromStringOrDefault(value: String?, default: WalletType = SAFE): WalletType {
if (value.isNullOrBlank()) {
return default
}
return values().find { it.value.equals(value, ignoreCase = true) } ?: default
}
/**
* 检查字符串是否为有效的钱包类型
*/
fun isValid(value: String?): Boolean {
if (value.isNullOrBlank()) {
return false
}
return values().any { it.value.equals(value, ignoreCase = true) }
}
}
}
@@ -29,5 +29,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
* 检查钱包地址是否存在
*/
fun existsByWalletAddress(walletAddress: String): Boolean
/**
* 检查代理地址是否存在
*/
fun existsByProxyAddress(proxyAddress: String): Boolean
}
@@ -0,0 +1,63 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.BacktestTask
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
/**
* 回测任务Repository
*/
@Repository
interface BacktestTaskRepository : JpaRepository<BacktestTask, Long> {
/**
* 根据 Leader ID 查询回测任务
*/
fun findByLeaderId(leaderId: Long): List<BacktestTask>
/**
* 根据状态查询回测任务
*/
fun findByStatus(status: String): List<BacktestTask>
/**
* 根据 Leader ID 和状态查询回测任务
*/
fun findByLeaderIdAndStatus(leaderId: Long, status: String): List<BacktestTask>
/**
* 根据 Leader ID、收益率排序查询
*/
@Query("SELECT t FROM BacktestTask t WHERE t.leaderId = :leaderId AND t.status = :status ORDER BY t.profitRate DESC")
fun findByLeaderIdAndStatusOrderByProfitRateDesc(leaderId: Long, status: String): List<BacktestTask>
/**
* 根据状态和创建时间倒序查询
*/
@Query("SELECT t FROM BacktestTask t WHERE t.status = :status ORDER BY t.createdAt DESC")
fun findByStatusOrderByCreatedAtDesc(status: String): List<BacktestTask>
/**
* 更新回测任务状态
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.status = :status, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateStatus(id: Long, status: String, updatedAt: Long = System.currentTimeMillis())
/**
* 更新回测任务状态和错误信息
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.status = :status, t.errorMessage = :errorMessage, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateStatusAndError(id: Long, status: String, errorMessage: String?, updatedAt: Long = System.currentTimeMillis())
/**
* 更新回测任务进度
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.progress = :progress, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateProgress(id: Long, progress: Int, updatedAt: Long = System.currentTimeMillis())
}
@@ -0,0 +1,38 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.BacktestTrade
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
/**
* 回测交易记录Repository
*/
@Repository
interface BacktestTradeRepository : JpaRepository<BacktestTrade, Long> {
/**
* 根据回测任务ID查询所有交易记录
*/
fun findByBacktestTaskIdOrderByTradeTime(backtestTaskId: Long): List<BacktestTrade>
/**
* 根据回测任务ID分页查询交易记录
*/
@Query("SELECT t FROM BacktestTrade t WHERE t.backtestTaskId = :backtestTaskId ORDER BY t.tradeTime")
fun findByBacktestTaskId(
backtestTaskId: Long,
pageable: org.springframework.data.domain.Pageable
): org.springframework.data.domain.Page<BacktestTrade>
/**
* 根据回测任务ID统计交易数量
*/
fun countByBacktestTaskId(backtestTaskId: Long): Long
/**
* 删除回测任务的所有交易记录(由级联删除处理)
*/
fun deleteByBacktestTaskId(backtestTaskId: Long)
}
@@ -3,10 +3,12 @@ package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.getEventSlug
import com.wrbug.polymarketbot.service.common.PolymarketClobService
@@ -66,11 +68,6 @@ class AccountService(
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 检查地址是否已存在
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
}
// 3. 验证私钥和地址的对应关系
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
@@ -104,7 +101,8 @@ class AccountService(
// 5. 获取代理地址(必须成功,否则导入失败)
// 根据用户选择的钱包类型计算代理地址
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, walletTypeEnum)
if (proxyResult.isSuccess) {
val address = proxyResult.getOrNull()
if (address != null) {
@@ -120,25 +118,31 @@ class AccountService(
}
}
// 6. 按代理地址去重:该代理地址已存在则不允许重复导入
if (accountRepository.existsByProxyAddress(proxyAddress)) {
return Result.failure(IllegalArgumentException("ACCOUNT_ALREADY_EXISTS"))
}
// 7. 加密敏感信息
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
// 8. 生成账户名称(如果未提供,使用钱包地址后位)
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
val accountName = if (request.accountName.isNullOrBlank()) {
val walletAddress = request.walletAddress.trim()
// 取地址后四位(去掉 0x 前缀后取后四位)
val addressWithoutPrefix = if (walletAddress.startsWith("0x") || walletAddress.startsWith("0X")) {
walletAddress.substring(2)
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
val typeLabel = walletTypeEnum.name.uppercase()
val proxyWithoutPrefix = if (proxyAddress.startsWith("0x") || proxyAddress.startsWith("0X")) {
proxyAddress.substring(2)
} else {
walletAddress
proxyAddress
}
if (addressWithoutPrefix.length >= 4) {
addressWithoutPrefix.substring(addressWithoutPrefix.length - 4).uppercase()
val suffix = if (proxyWithoutPrefix.length >= 4) {
proxyWithoutPrefix.substring(proxyWithoutPrefix.length - 4).uppercase()
} else {
addressWithoutPrefix.uppercase()
proxyWithoutPrefix.uppercase()
}
"$typeLabel-$suffix"
} else {
request.accountName.trim()
}
@@ -171,6 +175,192 @@ class AccountService(
}
}
/**
* 检查代理地址选项(用于账户导入前选择代理类型)
* 私钥导入:返回 Magic 和 Safe 两个选项
* 助记词导入:仅返回 Safe 选项
*/
suspend fun checkProxyOptions(request: CheckProxyOptionsRequest): Result<CheckProxyOptionsResponse> {
return try {
// 1. 验证钱包地址格式
if (!isValidWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 验证至少提供了私钥或助记词之一
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("必须提供私钥或助记词"))
}
val options = mutableListOf<ProxyOptionDto>()
// 3. 判断导入类型
val isPrivateKeyImport = !request.privateKey.isNullOrBlank()
if (isPrivateKeyImport) {
// 私钥导入:并行获取 Magic 和 Safe 代理地址及资产
coroutineScope {
val magicDeferred = async {
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.MAGIC).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
ProxyOptionDto(
walletType = WalletType.MAGIC.value,
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
} else {
ProxyOptionDto(
walletType = "magic",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Magic 代理地址失败"
)
}
} catch (e: Exception) {
logger.warn("获取 Magic 代理地址或资产失败: ${e.message}", e)
ProxyOptionDto(
walletType = "magic",
proxyAddress = blockchainService.calculateMagicProxyAddress(request.walletAddress),
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
}
}
val safeDeferred = async {
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
ProxyOptionDto(
walletType = WalletType.SAFE.value,
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
} else {
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Safe 代理地址失败"
)
}
} catch (e: Exception) {
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
}
}
val magicOption = magicDeferred.await()
val safeOption = safeDeferred.await()
// Safe 在前,Magic 在后
options.add(safeOption)
options.add(magicOption)
}
} else {
// 助记词导入:仅获取 Safe 代理地址及资产
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
)
} else {
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Safe 代理地址失败"
)
)
}
} catch (e: Exception) {
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
)
}
}
Result.success(CheckProxyOptionsResponse(options = options))
} catch (e: Exception) {
logger.error("检查代理地址选项失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 更新账户信息
*/
@@ -827,7 +1017,7 @@ class AccountService(
"0"
}
// 11. 创建并签名订单(使用计算后的卖出数量)
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -836,7 +1026,7 @@ class AccountService(
side = "SELL",
price = sellPrice,
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
signatureType = 2, // Browser Wallet(与正确订单数据一致)
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = expiration
@@ -1189,13 +1379,6 @@ class AccountService(
*/
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
return try {
// 检查 Builder API Key 是否已配置
if (!relayClientService.isBuilderApiKeyConfigured()) {
return Result.failure(
IllegalStateException("Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。")
)
}
if (request.positions.isEmpty()) {
return Result.failure(IllegalArgumentException("赎回仓位列表不能为空"))
}
@@ -1217,7 +1400,17 @@ class AccountService(
accounts[accountId] = account
}
// 4. 验证并收集要赎回的仓位信息(按账户分组
// 4. 若涉及 Magic 账户,必须已配置 Builder API Key(提前判断,避免执行到深层再报错
val hasMagicAccount = accounts.values.any {
WalletType.fromStringOrDefault(it.walletType, WalletType.SAFE) == WalletType.MAGIC
}
if (hasMagicAccount && !relayClientService.isBuilderApiKeyConfigured()) {
return Result.failure(
IllegalStateException("Builder API Key 未配置,无法执行 Magic 账户赎回(Gasless)。请前往系统设置页面配置 Builder API Key。")
)
}
// 5. 验证并收集要赎回的仓位信息(按账户分组)
val accountRedeemData = mutableMapOf<Long, MutableList<Pair<AccountPositionDto, BigInteger>>>()
val accountRedeemedInfo =
mutableMapOf<Long, MutableList<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>>()
@@ -1260,7 +1453,7 @@ class AccountService(
accountRedeemedInfo[accountId] = accountInfo
}
// 5. 对每个账户执行赎回
// 6. 对每个账户执行赎回Safe 与 Magic 均支持,Magic 通过 Builder Relayer PROXY Gasless 执行)
val accountTransactions = mutableListOf<com.wrbug.polymarketbot.dto.AccountRedeemTransaction>()
var totalRedeemedValue = BigDecimal.ZERO
@@ -1280,11 +1473,13 @@ class AccountService(
val decryptedPrivateKey = decryptPrivateKey(account)
// 调用区块链服务赎回仓位
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
val redeemResult = blockchainService.redeemPositions(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets
indexSets = indexSets,
walletType = walletTypeEnum
)
redeemResult.fold(
@@ -1315,7 +1510,7 @@ class AccountService(
)
}
// 6. 发送赎回推送通知(异步,不阻塞)
// 7. 发送赎回推送通知(异步,不阻塞)
notificationScope.launch {
try {
// 获取当前语言设置
@@ -430,27 +430,20 @@ class PositionCheckService(
processedRedeemablePositions[positionKey] = now
}
// 赎回成功后,再查找订单并更新订单状态
// 赎回成功后,按每个跟单配置分别查找未卖出订单并更新状态
// 同一账户同一市场可能同时跟多个 Leader,需按 copyTradingId 分别生成自动卖出记录(如 leader1 对应 20 shareleader2 对应 16 share
for (position in positionsToRedeem) {
// 查找相同仓位的未卖出订单(remaining_quantity > 0
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
for (copyTrading in copyTradings) {
if (position.outcomeIndex != null) {
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id!!,
position.marketId,
position.outcomeIndex
)
unmatchedOrders.addAll(orders)
}
if (position.outcomeIndex == null) {
continue
}
// 如果有未卖出订单,更新订单状态
if (unmatchedOrders.isNotEmpty()) {
// 从订单中获取 copyTradingId(所有订单应该有相同的 copyTradingId
val copyTradingId = unmatchedOrders.firstOrNull()?.copyTradingId
if (copyTradingId != null) {
updateOrdersAsSoldAfterRedeem(unmatchedOrders, position, copyTradingId)
for (copyTrading in copyTradings) {
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id!!,
position.marketId,
position.outcomeIndex
)
if (orders.isNotEmpty()) {
updateOrdersAsSoldAfterRedeem(orders, position, copyTrading.id!!)
}
}
}
@@ -0,0 +1,137 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.UserActivityResponse
import com.wrbug.polymarketbot.dto.TradeData
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.delay
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
/**
* 基于 start 游标的一批历史交易结果
* @param trades 本批交易列表(已按时间升序)
* @param nextCursorSeconds 下一页游标(API 的 start 参数,秒级);若本批不足 limit 条则为 null 表示最后一页
*/
data class LeaderTradesBatchResult(
val trades: List<TradeData>,
val nextCursorSeconds: Long?
)
/**
* 回测数据服务
* 直接从 Polymarket Data API 获取 Leader 历史交易,使用 start 游标分页(避免 offset 过大报错)
*/
@Service
class BacktestDataService(
private val leaderRepository: LeaderRepository,
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(BacktestDataService::class.java)
/**
* 按 start 游标获取一批 Leader 历史交易
* 规则:limit 固定为 500;若返回 500 条则取本批最大时间戳(秒)作为下一页 start,不加 1(同一秒可能多笔订单,由下游按 tradeId 去重);不足 500 则为最后一页
*
* @param leaderId Leader ID
* @param startTime 回测开始时间(毫秒)
* @param endTime 回测结束时间(毫秒)
* @param cursorStartSeconds 本页游标(API 的 start,秒);首次传 startTime/1000
* @param limit 每批条数,建议 500
* @return 本批交易与下一页游标(null 表示没有下一页)
*/
suspend fun getLeaderHistoricalTradesBatch(
leaderId: Long,
startTime: Long,
endTime: Long,
cursorStartSeconds: Long,
limit: Int
): LeaderTradesBatchResult {
logger.info("获取 Leader 历史交易批次: leaderId=$leaderId, cursorStart=$cursorStartSeconds, limit=$limit")
val leader = leaderRepository.findById(leaderId).orElse(null)
?: throw IllegalArgumentException("Leader 不存在: $leaderId")
val dataApi = retrofitFactory.createDataApi()
val endSeconds = endTime / 1000
val maxRetries = 5
val retryDelay = 1000L
var lastException: Exception? = null
for (attempt in 1..maxRetries) {
try {
val response = dataApi.getUserActivity(
user = leader.leaderAddress,
type = listOf("TRADE"),
start = cursorStartSeconds,
end = endSeconds,
limit = limit,
offset = null,
sortBy = "TIMESTAMP",
sortDirection = "ASC"
)
if (!response.isSuccessful || response.body() == null) {
throw Exception("从 Data API 获取用户活动失败: code=${response.code()}, message=${response.message()}")
}
val activities = response.body()!!
logger.info("本批获取 ${activities.size} 条活动(第 $attempt 次尝试)")
val trades = activities.mapNotNull { activity ->
try {
if (activity.type != "TRADE") return@mapNotNull null
if (activity.side == null || activity.price == null || activity.size == null || activity.usdcSize == null) {
logger.warn("活动数据缺少必要字段,跳过: activity=$activity")
return@mapNotNull null
}
val tradeTimestamp = activity.timestamp * 1000
if (tradeTimestamp < startTime || tradeTimestamp > endTime) {
logger.debug("交易时间超出范围,跳过: timestamp=$tradeTimestamp")
return@mapNotNull null
}
TradeData(
tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}",
marketId = activity.conditionId,
marketTitle = activity.title,
marketSlug = activity.slug,
side = activity.side.uppercase(),
outcome = activity.outcome ?: activity.outcomeIndex?.toString() ?: "",
outcomeIndex = activity.outcomeIndex,
price = activity.price.toSafeBigDecimal(),
size = activity.size.toSafeBigDecimal(),
amount = activity.usdcSize.toSafeBigDecimal(),
timestamp = tradeTimestamp
)
} catch (e: Exception) {
logger.warn("转换活动数据失败: activity=$activity, error=${e.message}", e)
null
}
}
// 下一页 start 用本批最大 timestamp(秒),不加 1:同一秒可能有多笔订单,依赖下游按 tradeId 去重
val nextCursorSeconds: Long? = if (trades.size < limit) {
null
} else {
val maxTs = trades.maxOf { it.timestamp }
maxTs / 1000
}
return LeaderTradesBatchResult(trades = trades, nextCursorSeconds = nextCursorSeconds)
} catch (e: Exception) {
lastException = e
logger.warn("$attempt/$maxRetries 次获取批次失败: ${e.message}")
if (attempt < maxRetries) {
logger.info("等待 $retryDelay 毫秒后重试...")
delay(retryDelay)
}
}
}
val errorMsg = "重试 $maxRetries 次后仍然失败,cursorStart=$cursorStartSeconds"
logger.error(errorMsg, lastException)
throw Exception(errorMsg, lastException)
}
}
@@ -0,0 +1,792 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.dto.TradeData
import com.wrbug.polymarketbot.dto.BacktestStatisticsDto
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.entity.BacktestTrade
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.service.common.MarketPriceService
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.max
import kotlin.math.min
@Service
class BacktestExecutionService(
private val backtestTaskRepository: BacktestTaskRepository,
private val backtestTradeRepository: BacktestTradeRepository,
private val backtestDataService: BacktestDataService,
private val marketPriceService: MarketPriceService,
private val marketService: MarketService,
private val copyTradingFilterService: CopyTradingFilterService
) {
private val logger = LoggerFactory.getLogger(BacktestExecutionService::class.java)
/**
* 持仓数据结构
* @param marketEndDate 市场结束时间(毫秒),用于到期结算判断,null 表示未知
*/
data class Position(
val marketId: String,
val outcome: String,
val outcomeIndex: Int?,
var quantity: BigDecimal,
val avgPrice: BigDecimal,
val leaderBuyQuantity: BigDecimal?,
val marketEndDate: Long? = null
)
/**
* 将回测任务转换为虚拟的 CopyTrading 配置用于执行
* 注意:回测场景使用历史数据,不需要实时跟单的相关配置
*/
private fun taskToCopyTrading(task: BacktestTask): CopyTrading {
return CopyTrading(
id = task.id,
accountId = 0L,
leaderId = task.leaderId,
enabled = true,
copyMode = task.copyMode,
copyRatio = task.copyRatio,
fixedAmount = null,
maxOrderSize = task.maxOrderSize,
minOrderSize = task.minOrderSize,
maxDailyLoss = task.maxDailyLoss,
maxDailyOrders = task.maxDailyOrders,
priceTolerance = BigDecimal.ZERO, // 回测使用历史价格,不需要容忍度
delaySeconds = 0, // 回测按时间线执行,无需延迟
pollIntervalSeconds = 5,
useWebSocket = false,
websocketReconnectInterval = 5000,
websocketMaxRetries = 10,
supportSell = task.supportSell,
minOrderDepth = null, // 回测无实时订单簿数据
maxSpread = null, // 回测无实时价差数据
maxPositionValue = task.maxPositionValue,
minPrice = task.minPrice, // 最低价格
maxPrice = task.maxPrice, // 最高价格
keywordFilterMode = task.keywordFilterMode,
keywords = task.keywords,
configName = null,
pushFailedOrders = false,
pushFilteredOrders = false,
createdAt = task.createdAt,
updatedAt = task.updatedAt
)
}
/**
* 执行回测任务(支持分页和恢复)
* 自动处理所有页面的数据,支持中断恢复
*/
/** 每批请求 API 的条数(基于 start 游标分页,避免 offset 过大) */
private val backtestBatchLimit = 500
@Transactional
suspend fun executeBacktest(task: BacktestTask, page: Int = 1, size: Int = 100) {
try {
logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}, batchLimit=$backtestBatchLimit")
// 1. 更新任务状态为 RUNNING
task.status = "RUNNING"
task.executionStartedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
// 2. 初始化
var currentBalance = task.initialBalance
val positions = mutableMapOf<String, Position>()
val trades = mutableListOf<BacktestTrade>()
val dailyOrderCountCache = mutableMapOf<String, Int>()
val dailyLossCache = mutableMapOf<String, BigDecimal>()
val seenTradeIds = mutableSetOf<String>()
// 3. 回测时间范围:首次执行以当前时间为基准取最近 backtestDays 天;断点续跑保留原 startTime,仅 endTime 延到当前
val endTime = System.currentTimeMillis()
val startTime = if (task.lastProcessedTradeTime == null) {
endTime - (task.backtestDays * 24L * 3600 * 1000)
} else {
task.startTime
}
logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)} (${task.backtestDays} 天), " +
"初始余额: ${task.initialBalance.toPlainString()}")
// 4. 游标分页:恢复时也从 lastProcessedTradeTime 所在秒开始拉(不加 1),与分页规则一致;已处理的通过 timestamp 跳过,不依赖内存 seenTradeIds
var cursorSeconds = if (task.lastProcessedTradeTime != null) {
task.lastProcessedTradeTime!! / 1000
} else {
startTime / 1000
}
val endSeconds = endTime / 1000
val resumeThresholdMs = task.lastProcessedTradeTime ?: 0L
logger.info("开始游标分页:cursorStart=$cursorSeconds(恢复则跳过 timestamp<=${resumeThresholdMs}ms")
var terminateBacktest = false
while (true) {
if (terminateBacktest) {
logger.info("余额已为负或不足,终止回测循环")
break
}
val currentTaskStatus = backtestTaskRepository.findById(task.id!!).orElse(null)
if (currentTaskStatus == null || currentTaskStatus.status != "RUNNING") {
logger.info("回测任务状态已变更: ${currentTaskStatus?.status},停止执行")
break
}
logger.info("正在获取批次数据 cursorStart=$cursorSeconds (${formatTimestamp(cursorSeconds * 1000)}) ...")
val currentPageTrades = mutableListOf<BacktestTrade>()
try {
val batch = backtestDataService.getLeaderHistoricalTradesBatch(
task.leaderId,
startTime,
endTime,
cursorSeconds,
backtestBatchLimit
)
val pageTrades = batch.trades
if (pageTrades.isEmpty()) {
logger.info("本批无数据,所有数据处理完成")
break
}
logger.info("本批获取 ${pageTrades.size} 条交易,是否有下一页: ${batch.nextCursorSeconds != null}")
val countAtBatchStart = task.processedTradeCount
var lastProcessedIndexInPage: Int? = null
var processedInBatch = 0
for (localIndex in pageTrades.indices) {
val leaderTrade = pageTrades[localIndex]
if (leaderTrade.tradeId in seenTradeIds) {
logger.debug("跳过重复交易: ${leaderTrade.tradeId}")
continue
}
if (resumeThresholdMs > 0 && leaderTrade.timestamp <= resumeThresholdMs) {
logger.debug("恢复时跳过已处理时间戳: tradeId=${leaderTrade.tradeId}, timestamp=${leaderTrade.timestamp}")
continue
}
seenTradeIds.add(leaderTrade.tradeId)
val index = countAtBatchStart + processedInBatch
lastProcessedIndexInPage = index
processedInBatch++
// 进度按时间比例:(当前订单时间 - 开始时间) / (结束时间 - 开始时间) * 100,运行中上限 99
val timeRange = endTime - startTime
val progress = if (timeRange > 0) {
val elapsed = (leaderTrade.timestamp - startTime).coerceIn(0L, timeRange)
min(99, ((elapsed * 100) / timeRange).toInt())
} else {
0
}
if (progress > task.progress) {
task.progress = progress
task.processedTradeCount = index + 1
backtestTaskRepository.save(task)
}
try {
// 5.1 实时检查并结算已到期的市场
currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp, currentPageTrades)
// 5.2 检查余额和持仓状态
if (currentBalance <= BigDecimal.ONE) {
logger.info(
if (currentBalance < BigDecimal.ZERO) "余额已为负,直接终止回测: $currentBalance"
else "余额<=1,停止回测: $currentBalance"
)
terminateBacktest = true
break
}
// 5.3 应用过滤规则
val copyTrading = taskToCopyTrading(task)
val filterResult = copyTradingFilterService.checkFilters(
copyTrading,
tokenId = "",
tradePrice = leaderTrade.price,
copyOrderAmount = null,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
marketEndDate = null,
outcomeIndex = leaderTrade.outcomeIndex
)
if (!filterResult.isPassed) {
logger.debug("交易被过滤: ${leaderTrade.tradeId}")
continue
}
// 5.4 每日订单数检查 - 使用缓存,只统计 BUY 订单
val tradeDate = formatDate(leaderTrade.timestamp)
val dailyOrderCount = dailyOrderCountCache.getOrDefault(tradeDate, 0)
if (dailyOrderCount >= task.maxDailyOrders) {
logger.info("已达到每日最大 BUY 订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
continue
}
// 5.6 计算跟单金额
val followAmount = calculateFollowAmount(task, leaderTrade)
// 5.6.1 检查订单大小限制
val finalFollowAmount = if (followAmount > task.maxOrderSize) {
logger.info("跟单金额超过最大限制: $followAmount > ${task.maxOrderSize},调整为最大值")
task.maxOrderSize
} else if (followAmount < task.minOrderSize) {
logger.info("跟单金额低于最小限制: $followAmount < ${task.minOrderSize},调整为最小值")
task.minOrderSize
} else {
followAmount
}
// 5.6.2 检查每日最大亏损(买入订单)- 使用缓存
val dailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
if (dailyLoss > task.maxDailyLoss) {
logger.info("已达到每日最大亏损限制: $dailyLoss / ${task.maxDailyLoss},跳过买入订单")
continue
}
// 5.7 处理买卖逻辑
if (leaderTrade.side == "BUY") {
// 余额不足时按最大可用余额交易,仍须满足最小订单金额
val actualBuyAmount = if (currentBalance < finalFollowAmount) {
logger.debug("余额不足,按最大余额买入: balance=$currentBalance, 原需=$finalFollowAmount, marketId=${leaderTrade.marketId}")
currentBalance
} else {
finalFollowAmount
}
if (actualBuyAmount < task.minOrderSize) {
logger.debug("可用金额低于最小订单限制跳过: actual=$actualBuyAmount, minOrderSize=${task.minOrderSize}")
continue
}
val quantity = actualBuyAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN)
if (quantity <= BigDecimal.ZERO) {
logger.debug("计算数量为0跳过: actualBuyAmount=$actualBuyAmount, price=${leaderTrade.price}")
continue
}
val totalCost = actualBuyAmount
// 5.6.3 检查最大仓位限制(如果配置了)
if (task.maxPositionValue != null) {
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val currentPosition = positions[positionKey]
val currentPositionValue = if (currentPosition != null) {
currentPosition.quantity.multiply(currentPosition.avgPrice)
} else {
BigDecimal.ZERO
}
val totalValueAfterOrder = currentPositionValue.add(actualBuyAmount)
if (totalValueAfterOrder.gt(task.maxPositionValue)) {
val currentPositionValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
val maxValueStr = task.maxPositionValue.stripTrailingZeros().toPlainString()
logger.info("超过最大仓位金额限制: 市场=${leaderTrade.marketId}, 方向=${leaderTrade.outcomeIndex}, 当前仓位=${currentPositionValueStr} USDC, 买入金额=${actualBuyAmount} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC")
continue
}
}
// 更新余额和持仓(同市场同 outcome 多次买入合并:数量相加、加权均价、leaderBuyQuantity 相加)
currentBalance -= totalCost
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val price = leaderTrade.price.toSafeBigDecimal()
val leaderSize = leaderTrade.size.toSafeBigDecimal()
val existing = positions[positionKey]
positions[positionKey] = if (existing != null) {
val newQuantity = existing.quantity.add(quantity)
val newAvgPrice = if (newQuantity > BigDecimal.ZERO) {
existing.quantity.multiply(existing.avgPrice).add(quantity.multiply(price))
.divide(newQuantity, 8, java.math.RoundingMode.HALF_UP)
} else {
price
}
val newLeaderBuyQuantity = (existing.leaderBuyQuantity ?: BigDecimal.ZERO).add(leaderSize)
Position(
marketId = leaderTrade.marketId,
outcome = leaderTrade.outcome ?: "",
outcomeIndex = leaderTrade.outcomeIndex,
quantity = newQuantity,
avgPrice = newAvgPrice,
leaderBuyQuantity = newLeaderBuyQuantity,
marketEndDate = existing.marketEndDate
)
} else {
val market = marketService.getMarket(leaderTrade.marketId)
Position(
marketId = leaderTrade.marketId,
outcome = leaderTrade.outcome ?: "",
outcomeIndex = leaderTrade.outcomeIndex,
quantity = quantity,
avgPrice = price,
leaderBuyQuantity = leaderSize,
marketEndDate = market?.endDate
)
}
// 记录交易到当前页列表
currentPageTrades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = leaderTrade.timestamp,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
side = "BUY",
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
outcomeIndex = leaderTrade.outcomeIndex,
quantity = quantity,
price = leaderTrade.price.toSafeBigDecimal(),
amount = actualBuyAmount,
fee = BigDecimal.ZERO,
profitLoss = null,
balanceAfter = currentBalance,
leaderTradeId = leaderTrade.tradeId
))
// 更新每日订单数缓存
dailyOrderCountCache[tradeDate] = dailyOrderCount + 1
} else {
// SELL 逻辑
if (!task.supportSell) {
continue
}
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val position = positions[positionKey] ?: continue
// 计算卖出数量
val sellQuantity = if (task.copyMode == "RATIO") {
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
position.quantity.multiply(
leaderTrade.size.divide(position.leaderBuyQuantity, 8, java.math.RoundingMode.DOWN)
)
} else {
position.quantity
}
} else {
position.quantity
}
val actualSellQuantity = if (sellQuantity > position.quantity) {
position.quantity
} else {
sellQuantity
}
// 计算卖出金额
val sellAmount = actualSellQuantity.multiply(leaderTrade.price.toSafeBigDecimal())
// 5.6.2 检查卖出金额限制
val finalSellAmount = if (sellAmount > task.maxOrderSize) {
logger.info("卖出金额超过最大限制: $sellAmount > ${task.maxOrderSize},调整为最大值")
task.maxOrderSize
} else if (sellAmount < task.minOrderSize) {
logger.info("卖出金额低于最小限制: $sellAmount < ${task.minOrderSize},调整为最小值")
task.minOrderSize
} else {
sellAmount
}
val netAmount = finalSellAmount
// 计算盈亏
val cost = actualSellQuantity.multiply(position.avgPrice)
val profitLoss = netAmount.subtract(cost)
// 更新余额和持仓
currentBalance += netAmount
if (position.quantity <= BigDecimal.ZERO) {
positions.remove(positionKey)
}
// 记录交易到当前页列表
currentPageTrades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = leaderTrade.timestamp,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
side = "SELL",
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
outcomeIndex = leaderTrade.outcomeIndex,
quantity = actualSellQuantity,
price = leaderTrade.price.toSafeBigDecimal(),
amount = finalSellAmount,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = currentBalance,
leaderTradeId = leaderTrade.tradeId
))
// SELL 订单不计入每日订单数限制
// 更新每日亏损缓存(只累加亏损,不累加盈利)
if (profitLoss < BigDecimal.ZERO) {
val currentDailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
dailyLossCache[tradeDate] = currentDailyLoss + profitLoss.negate()
}
}
} catch (e: Exception) {
logger.error("处理交易失败: tradeId=${leaderTrade.tradeId}", e)
}
}
// 保存本批交易
if (currentPageTrades.isNotEmpty()) {
logger.info("保存本批交易,共 ${currentPageTrades.size}")
backtestTradeRepository.saveAll(currentPageTrades)
val lastTradeInPage = currentPageTrades.lastOrNull()
if (lastTradeInPage != null && lastProcessedIndexInPage != null) {
task.lastProcessedTradeTime = lastTradeInPage.tradeTime
task.lastProcessedTradeIndex = lastProcessedIndexInPage
task.processedTradeCount = lastProcessedIndexInPage + 1
task.finalBalance = currentBalance
backtestTaskRepository.save(task)
logger.info("本批处理完成,lastProcessedTradeIndex=${task.lastProcessedTradeIndex}, 总处理数=${task.processedTradeCount}")
}
} else {
logger.info("本批没有交易需要保存")
}
trades.addAll(currentPageTrades)
if (batch.nextCursorSeconds == null) {
logger.info("本批不足 $backtestBatchLimit 条,已是最后一页")
break
}
cursorSeconds = batch.nextCursorSeconds!!
} catch (e: Exception) {
logger.error("获取或处理本批数据失败: ${e.message}", e)
// 重试失败,标记任务为 FAILED
throw e
}
}
// 6. 处理回测结束时仍未到期的持仓
val remainingSettlements = mutableListOf<BacktestTrade>()
currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime, remainingSettlements)
if (remainingSettlements.isNotEmpty()) {
backtestTradeRepository.saveAll(remainingSettlements)
logger.info("回测结束结算剩余持仓,持久化 ${remainingSettlements.size} 笔 SETTLEMENT(CLOSED)")
}
// 7. 计算最终统计数据
val statistics = calculateStatistics(trades)
// 8. 更新任务状态
val profitAmount = currentBalance.subtract(task.initialBalance)
val profitRate = if (task.initialBalance > BigDecimal.ZERO) {
profitAmount.divide(task.initialBalance, 4, java.math.RoundingMode.HALF_UP).multiply(BigDecimal("100"))
} else {
BigDecimal.ZERO
}
val finalStatus = if (task.status == "STOPPED") "STOPPED" else "COMPLETED"
task.finalBalance = currentBalance
task.profitAmount = profitAmount
task.profitRate = profitRate
task.endTime = endTime
task.status = finalStatus
task.progress = 100
task.totalTrades = trades.size
task.buyTrades = trades.count { it.side == "BUY" }
task.sellTrades = trades.count { it.side == "SELL" }
task.winTrades = statistics.winTrades
task.lossTrades = statistics.lossTrades
task.winRate = statistics.winRate.toSafeBigDecimal()
task.maxProfit = statistics.maxProfit.toSafeBigDecimal()
task.maxLoss = statistics.maxLoss.toSafeBigDecimal()
task.maxDrawdown = statistics.maxDrawdown.toSafeBigDecimal()
task.avgHoldingTime = statistics.avgHoldingTime
task.executionFinishedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
logger.info("回测任务执行完成: taskId=${task.id}, " +
"最终余额=${currentBalance.toPlainString()}, " +
"收益额=${task.profitAmount?.toPlainString()}, " +
"收益率=${task.profitRate?.toPlainString()}%, " +
"总交易数=${trades.size}, " +
"盈利率=${task.winRate?.toPlainString()}%")
} catch (e: Exception) {
logger.error("回测任务执行失败: taskId=${task.id}", e)
task.status = "FAILED"
task.errorMessage = e.message
task.executionFinishedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
throw e
}
}
/**
* 结算已到期的市场
* @param batchTradesToSave 本批要持久化的交易列表,到期结算(赎回/输)会追加到此列表并随本批一起落库
*/
private suspend fun settleExpiredPositions(
task: BacktestTask,
positions: MutableMap<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long,
batchTradesToSave: MutableList<BacktestTrade>
): BigDecimal {
var balance = currentBalance
for ((positionKey, position) in positions.toList()) {
try {
// 仅当市场已到期(结束时间 <= 当前回测时间)时才结算,避免未到期持仓被误结算
if (position.marketEndDate == null || position.marketEndDate!! > currentTime) {
logger.debug("持仓未到期跳过结算: marketId=${position.marketId}, endDate=${position.marketEndDate}, currentTime=$currentTime")
continue
}
// 获取市场当前价格
val marketPrice = marketPriceService.getCurrentMarketPrice(
position.marketId,
position.outcomeIndex ?: 0
)
val price = marketPrice.toSafeBigDecimal()
// 通过市场价格判断结算价格
val settlementPrice = when {
price >= BigDecimal("0.95") -> BigDecimal.ONE
price <= BigDecimal("0.05") -> BigDecimal.ZERO
else -> position.avgPrice
}
val settlementValue = position.quantity.multiply(settlementPrice)
val profitLoss = settlementValue.subtract(position.quantity.multiply(position.avgPrice))
balance += settlementValue
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
val settlementTrade = BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = currentTime,
marketId = position.marketId,
marketTitle = marketTitle,
side = "SETTLEMENT",
outcome = when {
settlementPrice == BigDecimal.ONE -> "WIN"
settlementPrice == BigDecimal.ZERO -> "LOSE"
else -> "UNKNOWN"
},
outcomeIndex = position.outcomeIndex,
quantity = position.quantity,
price = settlementPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = balance,
leaderTradeId = null
)
trades.add(settlementTrade)
batchTradesToSave.add(settlementTrade)
// 移除已结算的持仓
positions.remove(positionKey)
} catch (e: Exception) {
logger.error("结算市场失败: marketId=${position.marketId}, outcomeIndex=${position.outcomeIndex}", e)
}
}
return balance
}
/**
* 结算未到期持仓(回测结束时剩余持仓按均价平仓)
* @param settlementsToSave 本批结算记录会追加到此列表,调用方需落库
*/
private suspend fun settleRemainingPositions(
task: BacktestTask,
positions: MutableMap<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long,
settlementsToSave: MutableList<BacktestTrade>
): BigDecimal {
var balance = currentBalance
for ((positionKey, position) in positions.toList()) {
val quantity = position.quantity
val avgPrice = position.avgPrice
val settlementPrice = avgPrice
val settlementValue = quantity.multiply(settlementPrice)
val profitLoss = settlementValue.negate()
balance += settlementValue
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
val closedTrade = BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = currentTime,
marketId = position.marketId,
marketTitle = marketTitle,
side = "SETTLEMENT",
outcome = "CLOSED",
outcomeIndex = position.outcomeIndex,
quantity = quantity,
price = avgPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = balance,
leaderTradeId = null
)
trades.add(closedTrade)
settlementsToSave.add(closedTrade)
}
positions.clear()
return balance
}
/**
* 计算统计数据
*/
private fun calculateStatistics(trades: List<BacktestTrade>): BacktestStatisticsDto {
val buyTrades = trades.count { it.side == "BUY" }
val sellTrades = trades.count { it.side == "SELL" }
val winTrades = trades.count { it.profitLoss != null && it.profitLoss > BigDecimal.ZERO }
val lossTrades = trades.count { it.profitLoss != null && it.profitLoss < BigDecimal.ZERO }
var totalProfit = BigDecimal.ZERO
var totalLoss = BigDecimal.ZERO
var maxProfit = BigDecimal.ZERO
var maxLoss = BigDecimal.ZERO
// 计算最大回撤
var runningBalance = if (trades.isNotEmpty()) {
trades[0].balanceAfter?.toSafeBigDecimal() ?: BigDecimal.ZERO
} else {
BigDecimal.ZERO
}
var peakBalance = runningBalance
var maxDrawdown = BigDecimal.ZERO
for (i in trades.indices) {
val trade = trades[i]
val balance = trade.balanceAfter?.toSafeBigDecimal() ?: continue
if (trade.profitLoss != null) {
val pnl = trade.profitLoss.toSafeBigDecimal()
if (pnl > BigDecimal.ZERO) {
totalProfit += pnl
if (pnl > maxProfit) maxProfit = pnl
} else {
totalLoss += pnl
if (pnl < maxLoss) maxLoss = pnl
}
}
if (balance > peakBalance) {
peakBalance = balance
}
val drawdown = peakBalance - runningBalance
if (drawdown > maxDrawdown) {
maxDrawdown = drawdown
}
runningBalance = balance
}
// 计算平均持仓时间
var avgHoldingTime: Long? = null
if (trades.size > 1) {
var totalHoldingTime = 0L
var count = 0
for (i in 0 until trades.size - 1) {
val currentTrade = trades[i]
val nextTrade = trades[i + 1]
if (currentTrade.side == "BUY" && nextTrade.side == "SELL") {
val holdingTime = nextTrade.tradeTime - currentTrade.tradeTime
totalHoldingTime += holdingTime
count++
}
}
if (count > 0) {
avgHoldingTime = totalHoldingTime / count
}
}
return BacktestStatisticsDto(
totalTrades = trades.size,
buyTrades = buyTrades,
sellTrades = sellTrades,
winTrades = winTrades,
lossTrades = lossTrades,
winRate = if (buyTrades + sellTrades > 0) {
(winTrades.toBigDecimal().divide((buyTrades + sellTrades).toBigDecimal(), 4, java.math.RoundingMode.HALF_UP))
.multiply(BigDecimal("100"))
.toPlainString()
} else {
BigDecimal.ZERO.toPlainString()
},
maxProfit = maxProfit.toPlainString(),
maxLoss = maxLoss.toPlainString(),
maxDrawdown = maxDrawdown.toPlainString(),
avgHoldingTime = avgHoldingTime
)
}
/**
* 计算跟单金额
*/
private fun calculateFollowAmount(task: BacktestTask, leaderTrade: TradeData): BigDecimal {
return if (task.copyMode == "RATIO") {
// 比例模式:Leader 成交金额 × 跟单比例
leaderTrade.amount.toSafeBigDecimal().multiply(task.copyRatio)
} else {
// 固定金额模式:使用配置的固定金额
task.fixedAmount ?: leaderTrade.amount.toSafeBigDecimal()
}
}
/**
* 判断是否同一天
*/
private fun isSameDay(timestamp1: Long, timestamp2: Long): Boolean {
val cal1 = Calendar.getInstance().apply { timeInMillis = timestamp1 }
val cal2 = Calendar.getInstance().apply { timeInMillis = timestamp2 }
return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR)
}
/**
* 格式化时间戳
*/
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return sdf.format(Date(timestamp))
}
/**
* 格式化日期(用于缓存key)
*/
private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd")
return sdf.format(Date(timestamp))
}
}
@@ -0,0 +1,113 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.ThreadPoolExecutor
import kotlinx.coroutines.runBlocking
/**
* 回测轮询服务
* 定时获取待执行的回测任务并执行
*/
@Service
class BacktestPollingService(
private val backtestTaskRepository: BacktestTaskRepository,
private val executionService: BacktestExecutionService
) {
private val logger = LoggerFactory.getLogger(BacktestPollingService::class.java)
// 线程池:同一时刻只执行一个任务
private val executor: ExecutorService = Executors.newFixedThreadPool(1) as ThreadPoolExecutor
/**
* 轮询待执行的回测任务
* 每 10 秒执行一次
* 规则:同一时刻只执行一个任务,如果有多个待执行任务,按创建时间先后执行最早创建的
*/
@Scheduled(fixedDelay = 10000) // 10 秒
fun pollPendingTasks() {
try {
// 1. 检查是否有长时间处于 RUNNING 状态的任务(可能是应用重启导致的)
val runningTasks = backtestTaskRepository.findByStatus("RUNNING")
if (runningTasks.isNotEmpty()) {
val activeQueueSize = (executor as ThreadPoolExecutor).queue.size
val activeCount = (executor as ThreadPoolExecutor).activeCount
// 如果有线程池中没有活跃任务但有 RUNNING 状态的任务,说明是应用重启导致的
// 重置这些任务的状态为 PENDING,以便恢复执行
if (activeCount == 0 && runningTasks.isNotEmpty()) {
logger.info("检测到应用重启导致的异常 RUNNING 任务,重置为 PENDING 以便恢复")
runningTasks.forEach { task ->
val now = System.currentTimeMillis()
val executionStartedAt = task.executionStartedAt
val executionDuration = if (executionStartedAt != null) {
now - executionStartedAt
} else {
0L
}
// 如果任务执行时间超过 1 分钟,认为是异常状态
if (executionDuration > 60000) {
logger.info("重置异常 RUNNING 任务: taskId=${task.id}, executionStartedAt=$executionStartedAt, duration=${executionDuration}ms")
task.status = "PENDING"
task.updatedAt = now
backtestTaskRepository.save(task)
}
}
} else {
logger.debug("${runningTasks.size} 个任务正在执行,跳过本次轮询")
return
}
}
// 2. 查询所有 PENDING 状态的任务,按创建时间升序排序
val pendingTasks = backtestTaskRepository.findByStatus("PENDING")
.sortedBy { it.createdAt }
if (pendingTasks.isEmpty()) {
return
}
// 3. 只执行最早创建的任务
val taskToExecute = pendingTasks.first()
logger.info("找到 ${pendingTasks.size} 个待执行的回测任务,执行最早创建的任务: taskId=${taskToExecute.id}, createdAt=${taskToExecute.createdAt}")
// 4. 提交任务到线程池执行
executor.submit {
try {
// 执行前再次检查任务状态(防止并发执行)
val currentTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
if (currentTask == null || currentTask.status != "PENDING") {
logger.debug("任务状态已变更,跳过执行: taskId=${taskToExecute.id}, currentStatus=${currentTask?.status}")
return@submit
}
runBlocking {
// 使用 start 游标分页,恢复时由 lastProcessedTradeTime 决定从何时开始拉取
logger.info("执行回测任务: taskId=${currentTask.id}(游标分页,limit=500")
executionService.executeBacktest(currentTask, page = 0, size = 500)
}
} catch (e: Exception) {
logger.error("回测任务执行失败: taskId=${taskToExecute.id}", e)
// 更新任务状态为 FAILED
val failedTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
if (failedTask != null) {
failedTask.status = "FAILED"
failedTask.errorMessage = e.message
failedTask.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(failedTask)
}
}
}
} catch (e: Exception) {
logger.error("轮询回测任务失败", e)
}
}
}
@@ -0,0 +1,426 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.entity.BacktestTrade
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.toJson
import com.wrbug.polymarketbot.util.fromJson
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
/**
* 回测任务服务
*/
@Service
class BacktestService(
private val backtestTaskRepository: BacktestTaskRepository,
private val backtestTradeRepository: BacktestTradeRepository,
private val leaderRepository: LeaderRepository,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(BacktestService::class.java)
/**
* 创建回测任务
*/
@Transactional
fun createBacktestTask(request: BacktestCreateRequest): Result<BacktestTaskDto> {
return try {
// 1. 验证 Leader 是否存在
val leader = leaderRepository.findById(request.leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// 2. 验证回测天数
if (request.backtestDays < 1 || request.backtestDays > 15) {
return Result.failure(IllegalArgumentException("回测天数必须在 1-15 之间"))
}
// 3. 验证恢复页码(如果提供)
if (request.pageForResume != null && request.pageForResume < 1) {
return Result.failure(IllegalArgumentException("恢复页码必须大于 0"))
}
// 4. 验证初始金额
val initialBalance = request.initialBalance.toSafeBigDecimal()
if (initialBalance <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException("初始金额必须大于 0"))
}
// 4. 创建回测任务
val task = BacktestTask(
taskName = request.taskName.trim(),
leaderId = request.leaderId,
initialBalance = initialBalance,
backtestDays = request.backtestDays,
startTime = System.currentTimeMillis() - (request.backtestDays * 24 * 3600 * 1000),
status = "PENDING",
// 跟单配置(不包含 max_position_count
copyMode = request.copyMode ?: "RATIO",
copyRatio = request.copyRatio?.toSafeBigDecimal() ?: BigDecimal.ONE,
fixedAmount = request.fixedAmount?.toSafeBigDecimal(),
maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: "1000".toSafeBigDecimal(),
minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: "1".toSafeBigDecimal(),
maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: "10000".toSafeBigDecimal(),
maxDailyOrders = request.maxDailyOrders ?: 100,
supportSell = request.supportSell ?: true,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = if (request.keywords != null && request.keywords.isNotEmpty()) {
request.keywords.toJson()
} else {
null
},
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
)
backtestTaskRepository.save(task)
// 5. 转换为 DTO 返回
Result.success(task.toDto(leader))
} catch (e: Exception) {
logger.error("创建回测任务失败", e)
Result.failure(e)
}
}
/**
* 查询回测任务列表
*/
fun getBacktestTaskList(request: BacktestListRequest): Result<BacktestListResponse> {
return try {
// 获取所有符合条件的任务
val allTasks = when {
request.leaderId != null && request.status != null -> {
backtestTaskRepository.findByLeaderIdAndStatus(request.leaderId, request.status)
}
request.leaderId != null -> {
backtestTaskRepository.findByLeaderId(request.leaderId)
.filter { request.status == null || it.status == request.status }
}
request.status != null -> {
backtestTaskRepository.findByStatus(request.status)
}
else -> {
backtestTaskRepository.findAll()
}
}
// 排序
val sortedTasks = when (request.sortBy) {
"profitAmount" -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.profitAmount }
} else {
allTasks.sortedByDescending { it.profitAmount }
}
}
"profitRate" -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.profitRate }
} else {
allTasks.sortedByDescending { it.profitRate }
}
}
else -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.createdAt }
} else {
allTasks.sortedByDescending { it.createdAt }
}
}
}
// 分页
val total = sortedTasks.size
val pagedTasks = sortedTasks
.drop((request.page - 1) * request.size)
.take(request.size)
val list = pagedTasks.map { task ->
val leader = leaderRepository.findById(task.leaderId).orElse(null)
task.toDto(leader)
}
Result.success(
BacktestListResponse(
list = list,
total = total.toLong(),
page = request.page,
size = request.size
)
)
} catch (e: Exception) {
logger.error("查询回测任务列表失败", e)
Result.failure(e)
}
}
/**
* 查询回测任务详情
*/
fun getBacktestTaskDetail(request: BacktestDetailRequest): Result<BacktestDetailResponse> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
val leader = leaderRepository.findById(task.leaderId).orElse(null)
val config = BacktestConfigDto(
copyMode = task.copyMode,
copyRatio = task.copyRatio.toPlainString(),
fixedAmount = task.fixedAmount?.toPlainString(),
maxOrderSize = task.maxOrderSize.toPlainString(),
minOrderSize = task.minOrderSize.toPlainString(),
maxDailyLoss = task.maxDailyLoss.toPlainString(),
maxDailyOrders = task.maxDailyOrders,
supportSell = task.supportSell,
keywordFilterMode = task.keywordFilterMode,
keywords = if (task.keywords != null) {
task.keywords.fromJson<List<String>>()
} else {
emptyList()
},
maxPositionValue = task.maxPositionValue?.toPlainString(),
minPrice = task.minPrice?.toPlainString(),
maxPrice = task.maxPrice?.toPlainString()
)
val statistics = BacktestStatisticsDto(
totalTrades = task.totalTrades,
buyTrades = task.buyTrades,
sellTrades = task.sellTrades,
winTrades = task.winTrades,
lossTrades = task.lossTrades,
winRate = task.winRate?.toPlainString() ?: "0.00",
maxProfit = task.maxProfit?.toPlainString() ?: "0.00",
maxLoss = task.maxLoss?.toPlainString() ?: "0.00",
maxDrawdown = task.maxDrawdown?.toPlainString() ?: "0.00",
avgHoldingTime = task.avgHoldingTime
)
val taskDto = task.toDto(leader)
Result.success(
BacktestDetailResponse(
task = taskDto,
config = config,
statistics = statistics
)
)
} catch (e: Exception) {
logger.error("查询回测任务详情失败", e)
Result.failure(e)
}
}
/**
* 查询回测交易记录
*/
fun getBacktestTrades(request: BacktestTradeListRequest): Result<BacktestTradeListResponse> {
return try {
val pageRequest = PageRequest.of(
request.page - 1,
request.size,
Sort.by(Sort.Order.asc("tradeTime"))
)
val tradesPage = backtestTradeRepository.findByBacktestTaskId(
request.taskId,
pageRequest
)
val list = tradesPage.content.map { trade ->
BacktestTradeDto(
id = trade.id!!,
tradeTime = trade.tradeTime,
marketId = trade.marketId,
marketTitle = trade.marketTitle,
side = trade.side,
outcome = trade.outcome,
outcomeIndex = trade.outcomeIndex,
quantity = trade.quantity.toPlainString(),
price = trade.price.toPlainString(),
amount = trade.amount.toPlainString(),
fee = trade.fee.toPlainString(),
profitLoss = trade.profitLoss?.toPlainString(),
balanceAfter = trade.balanceAfter.toPlainString(),
leaderTradeId = trade.leaderTradeId
)
}
Result.success(
BacktestTradeListResponse(
list = list,
total = tradesPage.totalElements,
page = request.page,
size = request.size
)
)
} catch (e: Exception) {
logger.error("查询回测交易记录失败", e)
Result.failure(e)
}
}
/**
* 删除回测任务
*/
@Transactional
fun deleteBacktestTask(request: BacktestDeleteRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status == "RUNNING") {
return Result.failure(IllegalStateException("回测任务正在运行,无法删除"))
}
backtestTaskRepository.deleteById(request.id)
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除回测任务失败", e)
Result.failure(e)
}
}
/**
* 停止回测任务
*/
@Transactional
fun stopBacktestTask(request: BacktestStopRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status != "RUNNING") {
return Result.failure(IllegalArgumentException("回测任务未在运行中"))
}
task.status = "STOPPED"
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
Result.success(Unit)
} catch (e: Exception) {
logger.error("停止回测任务失败", e)
Result.failure(e)
}
}
/**
* 重试回测任务
* 从断点继续执行,保留已处理的交易记录
*/
@Transactional
fun retryBacktestTask(request: BacktestRetryRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status == "RUNNING") {
return Result.failure(IllegalArgumentException("回测任务正在运行中,无需重试"))
}
// 重置任务状态为 PENDING,进度保持不变
task.status = "PENDING"
task.errorMessage = null
task.updatedAt = System.currentTimeMillis()
// 不清理已处理的交易记录,保留恢复点
backtestTaskRepository.save(task)
Result.success(Unit)
} catch (e: Exception) {
logger.error("重试回测任务失败", e)
Result.failure(e)
}
}
/**
* 按当前配置重新测试:基于已完成的回测任务创建一份相同配置的新任务(名称可修改)
*/
@Transactional
fun rerunBacktestTask(request: BacktestRerunRequest): Result<BacktestTaskDto> {
return try {
val source = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (source.status != "COMPLETED") {
return Result.failure(IllegalStateException("仅支持对已完成的回测任务重新测试"))
}
val newTaskName = request.taskName?.trim()?.takeIf { it.isNotEmpty() }
?: "${source.taskName} (副本)"
val newTask = BacktestTask(
taskName = newTaskName,
leaderId = source.leaderId,
initialBalance = source.initialBalance,
backtestDays = source.backtestDays,
startTime = source.startTime,
status = "PENDING",
copyMode = source.copyMode,
copyRatio = source.copyRatio,
fixedAmount = source.fixedAmount,
maxOrderSize = source.maxOrderSize,
minOrderSize = source.minOrderSize,
maxDailyLoss = source.maxDailyLoss,
maxDailyOrders = source.maxDailyOrders,
supportSell = source.supportSell,
keywordFilterMode = source.keywordFilterMode,
keywords = source.keywords,
maxPositionValue = source.maxPositionValue,
minPrice = source.minPrice,
maxPrice = source.maxPrice
)
backtestTaskRepository.save(newTask)
val leader = leaderRepository.findById(newTask.leaderId).orElse(null)
Result.success(newTask.toDto(leader))
} catch (e: Exception) {
logger.error("按配置重新测试失败", e)
Result.failure(e)
}
}
}
/**
* 扩展函数:BacktestTask 转 DTO
*/
private fun BacktestTask.toDto(leader: Leader?): BacktestTaskDto {
return BacktestTaskDto(
id = this.id!!,
taskName = this.taskName,
leaderId = this.leaderId,
leaderName = leader?.leaderName,
leaderAddress = leader?.leaderAddress,
initialBalance = this.initialBalance.toPlainString(),
finalBalance = this.finalBalance?.toPlainString(),
profitAmount = this.profitAmount?.toPlainString(),
profitRate = this.profitRate?.toPlainString(),
backtestDays = this.backtestDays,
startTime = this.startTime,
endTime = this.endTime,
status = this.status,
progress = this.progress,
totalTrades = this.totalTrades,
createdAt = this.createdAt,
executionStartedAt = this.executionStartedAt,
executionFinishedAt = this.executionFinishedAt
)
}
@@ -10,6 +10,7 @@ import com.wrbug.polymarketbot.api.ValueResponse
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.dto.PositionDto
import com.wrbug.polymarketbot.dto.WalletBalanceResponse
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
@@ -93,13 +94,13 @@ class BlockchainService(
* 2. Safe ProxyMetaMask 钱包用户)- 通过合约调用获取地址
*
* @param walletAddress 用户的钱包地址(EOA
* @param walletType 钱包类型:"magic"(默认)或 "safe"
* @param walletType 钱包类型:MAGIC(默认)或 SAFE
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
suspend fun getProxyAddress(walletAddress: String, walletType: WalletType = WalletType.MAGIC): Result<String> {
return try {
when (walletType.lowercase()) {
"safe" -> {
when (walletType) {
WalletType.SAFE -> {
// Safe ProxyMetaMask 用户)
val safeProxyResult = getSafeProxyAddress(walletAddress)
if (safeProxyResult.isSuccess) {
@@ -110,7 +111,7 @@ class BlockchainService(
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
}
}
else -> {
WalletType.MAGIC -> {
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
@@ -580,39 +581,35 @@ class BlockchainService(
/**
* 赎回仓位
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
*
* 使用 RelayClientService 实现,完全参考 TypeScript 项目的实现方式
*
* Safe 账户通过代理 execTransaction 调用Magic 账户通过 Builder Relayer PROXYGasless)执行
*
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
* @param proxyAddress 代理地址(Gnosis Safe 代理钱包地址)
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
* @param conditionId 市场条件IDbytes32,必须是 0x 开头的 66 位十六进制字符串)
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex,例如 [1] 表示 outcome 0[2] 表示 outcome 1
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex
* @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径
* @return 交易哈希
*/
suspend fun redeemPositions(
privateKey: String,
proxyAddress: String,
conditionId: String,
indexSets: List<BigInteger>
indexSets: List<BigInteger>,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
// 验证参数
if (indexSets.isEmpty()) {
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
}
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
}
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 使用 RelayClientService 创建赎回交易并执行
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
relayClientService.execute(privateKey, proxyAddress, redeemTx)
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
} catch (e: Exception) {
logger.error("赎回仓位失败: ${e.message}", e)
Result.failure(e)
@@ -9,6 +9,8 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.BigInteger
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
/**
* 市场价格服务
@@ -27,6 +29,21 @@ class MarketPriceService(
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
/**
* 已结算市场的价格缓存
* Key: "marketId:outcomeIndex"
* Value: BigDecimal (1.0 0.0)
*
* 缓存策略
* - 最大缓存 10,000 个已结算市场
* - 永不过期已结算的市场状态永不改变
* - 内存占用约: 10,000 * ~100 bytes = ~1MB
*/
private val settledMarketCache: Cache<String, BigDecimal> = Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats() // 启用统计信息
.build()
/**
* 获取当前市场最新价
* 优先级
@@ -83,9 +100,20 @@ class MarketPriceService(
* - payout == 0输了 返回 0.0
* 如果市场未结算或查询失败返回 null
*
* 使用缓存优化已结算的市场结果会被缓存避免重复 RPC 调用
*
* @return Pair<BigDecimal?, Boolean> 第一个值是价格如果已结算第二个值表示是否发生了 RPC 错误execution reverted
*/
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
// 1. 先检查缓存
val cacheKey = "$marketId:$outcomeIndex"
val cachedPrice = settledMarketCache.getIfPresent(cacheKey)
if (cachedPrice != null) {
logger.debug("从缓存获取已结算市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$cachedPrice")
return Pair(cachedPrice, false)
}
// 2. 缓存未命中,发起 RPC 查询
return try {
val chainResult = blockchainService.getCondition(marketId)
chainResult.fold(
@@ -96,11 +124,17 @@ class MarketPriceService(
when {
payout > BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return Pair(BigDecimal.ONE, false)
val price = BigDecimal.ONE
// 缓存已结算的结果
settledMarketCache.put(cacheKey, price)
return Pair(price, false)
}
payout == BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return Pair(BigDecimal.ZERO, false)
val price = BigDecimal.ZERO
// 缓存已结算的结果
settledMarketCache.put(cacheKey, price)
return Pair(price, false)
}
else -> {
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
@@ -109,7 +143,7 @@ class MarketPriceService(
}
} else {
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
Pair(null, false)
Pair(null, false) // 未结算的市场不缓存
}
},
onFailure = { e ->
@@ -290,5 +324,29 @@ class MarketPriceService(
}
}
/**
* 获取缓存统计信息
* 用于监控缓存命中率和性能
*/
fun getCacheStats(): String {
val stats = settledMarketCache.stats()
return """
已结算市场缓存统计:
- 缓存条目数: ${settledMarketCache.estimatedSize()}
- 命中次数: ${stats.hitCount()}
- 未命中次数: ${stats.missCount()}
- 命中率: ${"%.2f".format(stats.hitRate() * 100)}%
- 总请求次数: ${stats.requestCount()}
""".trimIndent()
}
/**
* 清空缓存测试或管理用
*/
fun clearSettledMarketCache() {
settledMarketCache.invalidateAll()
logger.info("已清空已结算市场缓存")
}
}
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.service.copytrading.leaders
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.common.BlockchainService
@@ -20,9 +21,10 @@ class LeaderService(
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val copyTradingRepository: CopyTradingRepository,
private val backtestTaskRepository: BacktestTaskRepository,
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
/**
@@ -157,7 +159,8 @@ class LeaderService(
val leaderDtos = leaders.map { leader ->
val copyTradingCount = copyTradingRepository.countByLeaderId(leader.id!!)
toDto(leader, copyTradingCount)
val backtestCount = backtestTaskRepository.findByLeaderId(leader.id).size.toLong()
toDto(leader, copyTradingCount, backtestCount)
}
Result.success(
@@ -181,7 +184,8 @@ class LeaderService(
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId)
Result.success(toDto(leader, copyTradingCount))
val backtestCount = backtestTaskRepository.findByLeaderId(leaderId).size.toLong()
Result.success(toDto(leader, copyTradingCount, backtestCount))
} catch (e: Exception) {
logger.error("查询 Leader 详情失败", e)
Result.failure(e)
@@ -225,7 +229,7 @@ class LeaderService(
/**
* 转换为 DTO
*/
private fun toDto(leader: Leader, copyTradingCount: Long = 0): LeaderDto {
private fun toDto(leader: Leader, copyTradingCount: Long = 0, backtestCount: Long = 0): LeaderDto {
return LeaderDto(
id = leader.id!!,
leaderAddress = leader.leaderAddress,
@@ -234,6 +238,7 @@ class LeaderService(
remark = leader.remark,
website = leader.website,
copyTradingCount = copyTradingCount,
backtestCount = backtestCount,
createdAt = leader.createdAt,
updatedAt = leader.updatedAt
)
@@ -19,9 +19,19 @@ import java.math.RoundingMode
*/
@Service
class OrderSigningService {
private val logger = LoggerFactory.getLogger(OrderSigningService::class.java)
/**
* 根据钱包类型返回 CLOB 订单签名类型
* @param walletType Magic=邮箱/社交登录, Safe=Web3 钱包
* @return 1=POLY_PROXY(Magic), 2=POLY_GNOSIS_SAFE(Safe), 默认 2
*/
fun getSignatureTypeForWalletType(walletType: String?): Int {
val walletTypeEnum = com.wrbug.polymarketbot.enums.WalletType.fromStringOrDefault(walletType, com.wrbug.polymarketbot.enums.WalletType.SAFE)
return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2
}
// Polygon 主网合约地址
private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
private val CHAIN_ID = 137L
@@ -185,6 +185,7 @@ open class CopyOrderTrackingService(
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
if (isUniqueConstraintViolation(e)) {
@@ -569,7 +570,8 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id!!,
tradeId = trade.id,
feeRateBps = feeRateBps
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
// 处理订单创建失败
@@ -993,7 +995,7 @@ open class CopyOrderTrackingService(
"0"
}
// 9. 创建并签名卖出订单
// 9. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -1002,7 +1004,7 @@ open class CopyOrderTrackingService(
side = "SELL",
price = sellPrice.toString(),
size = totalMatched.toString(),
signatureType = 2, // Browser Wallet
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0"
@@ -1043,7 +1045,8 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id,
tradeId = leaderSellTrade.id,
feeRateBps = feeRateBps
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
if (createOrderResult.isFailure) {
@@ -1136,6 +1139,7 @@ open class CopyOrderTrackingService(
* @param copyTradingId 跟单配置ID用于日志
* @param tradeId Leader 交易ID用于日志
* @param feeRateBps 费率基点从API动态获取
* @param signatureType 签名类型1=Magic, 2=Safe
* @return 成功返回订单ID失败返回异常
*/
private suspend fun createOrderWithRetry(
@@ -1149,7 +1153,8 @@ open class CopyOrderTrackingService(
owner: String,
copyTradingId: Long,
tradeId: String,
feeRateBps: String
feeRateBps: String,
signatureType: Int
): Result<String> {
var lastError: Exception? = null
@@ -1164,7 +1169,7 @@ open class CopyOrderTrackingService(
side = side,
price = price,
size = size,
signatureType = 2, // Browser Wallet
signatureType = signatureType,
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0"
@@ -4,6 +4,7 @@ import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.JsonRpcRequest
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
@@ -40,6 +41,15 @@ class RelayClientService(
// 空集合ID
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
// Polygon PROXYMagic)合约地址,参考 builder-relayer-client config
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
private val defaultProxyGasLimit = "10000000"
// Builder Relayer API 交易类型常量
private val RELAYER_TYPE_PROXY = "PROXY"
private val RELAYER_TYPE_SAFE = "SAFE"
private val polygonRpcApi: EthereumRpcApi by lazy {
val rpcUrl = rpcNodeService.getHttpUrl()
retrofitFactory.createEthereumRpcApi(rpcUrl)
@@ -201,33 +211,45 @@ class RelayClientService(
}
/**
* 执行 Safe 交易通过 Proxy.execTransaction
* 执行代理交易Safe Magic PROXY
* 参考 TypeScript: RelayClient.execute()
*
* 优先使用 Builder RelayerGasless如果未配置则回退到手动发送交易
*
* @param privateKey 私钥
* @param proxyAddress 代理钱包地址
* @param safeTx Safe 交易对象
* @param safeTx 交易对象to/data/value
* @param walletType 钱包类型MAGIC 使用 PROXY GaslessSAFE 使用 Safe 流程
* @return 交易哈希
*/
suspend fun execute(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction
safeTx: SafeTransaction,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
// 验证参数
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 检查 Builder API Key 是否已配置
val builderApiKey = systemConfigService.getBuilderApiKey()
val builderSecret = systemConfigService.getBuilderSecret()
val builderPassphrase = systemConfigService.getBuilderPassphrase()
// 优先使用 Builder RelayerGasless
if (walletType == WalletType.MAGIC) {
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
return Result.failure(IllegalStateException("Magic 账户赎回必须配置 Builder API KeyGasless"))
}
logger.info("使用 Builder Relayer PROXY 执行 Magic 赎回")
return executeViaBuilderRelayerProxy(
privateKey,
proxyAddress,
safeTx,
builderApiKey!!,
builderSecret!!,
builderPassphrase!!
)
}
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
logger.info("使用 Builder Relayer 执行 Gasless 交易")
return executeViaBuilderRelayer(
@@ -240,15 +262,240 @@ class RelayClientService(
)
}
// 回退到手动发送交易(需要用户支付 gas)
logger.info("Builder Relayer 未配置,使用手动发送交易(需要用户支付 gas)")
return executeManually(privateKey, proxyAddress, safeTx)
} catch (e: Exception) {
logger.error("执行 Safe 交易失败: ${e.message}", e)
logger.error("执行交易失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 Builder Relayer 执行 PROXYMagic交易Gasless
* 参考: builder-relayer-client client.ts executeProxyTransactions, builder/proxy.ts
*/
private suspend fun executeViaBuilderRelayerProxy(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction,
builderApiKey: String,
builderSecret: String,
builderPassphrase: String
): Result<String> {
val relayerApi = retrofitFactory.createBuilderRelayerApi(
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
apiKey = builderApiKey,
secret = builderSecret,
passphrase = builderPassphrase
)
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
val fromAddress = credentials.address
val relayPayloadResponse = relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY)
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
}
val relayPayload = relayPayloadResponse.body()!!
val relayAddress = relayPayload.address
val nonce = relayPayload.nonce
val proxyCallData = encodeProxyTransactionData(safeTx)
// 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit
val gasLimit = try {
estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData)
} catch (e: Exception) {
logger.warn("估算 PROXY gas limit 失败,使用默认值: ${e.message}", e)
defaultProxyGasLimit
}
val structHash = createProxyStructHash(
from = fromAddress,
to = proxyFactoryAddress,
data = proxyCallData,
txFee = "0",
gasPrice = "0",
gasLimit = gasLimit,
nonce = nonce,
relayHubAddress = relayHubAddress,
relayAddress = relayAddress
)
val prefix = "\u0019Ethereum Signed Message:\n32".toByteArray(Charsets.UTF_8)
val messageWithPrefix = ByteArray(prefix.size + structHash.size)
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
System.arraycopy(structHash, 0, messageWithPrefix, prefix.size, structHash.size)
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
val hashWithPrefix = ByteArray(keccak256.digestSize)
keccak256.doFinal(hashWithPrefix, 0)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val signature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
val sigHex = "0x" + org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') +
org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') +
String.format("%02x", (signature.v as ByteArray).getOrElse(0) { 0 }.toInt() and 0xff)
val request = BuilderRelayerApi.TransactionRequest(
type = RELAYER_TYPE_PROXY,
from = fromAddress,
to = proxyFactoryAddress,
proxyWallet = proxyAddress,
data = proxyCallData,
nonce = nonce,
signature = sigHex,
signatureParams = BuilderRelayerApi.SignatureParams(
gasPrice = "0",
gasLimit = gasLimit,
relayerFee = "0",
relayHub = relayHubAddress,
relay = relayAddress
),
metadata = "Redeem positions via Builder Relayer PROXY"
)
val response = relayerApi.submitTransaction(request)
if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误"
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
}
val relayerResponse = response.body()!!
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
logger.info("Builder Relayer PROXY 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
return Result.success(txHash)
}
/**
* 编码 ProxyFactory.proxy(calls) 调用数据
* 参考: builder-relayer-client encode/proxy.ts, abis proxyFactory proxy((uint8,address,uint256,bytes)[])
*
* ABI 编码规则 tuple 数组中的 tuple 包含动态类型bytes需要先存储 tuple offset
* 结构
* - selector (4 bytes)
* - array offset (32 bytes) = 32
* - array length (32 bytes) = 1
* - tuple[0] offset (32 bytes) = 32 (指向 tuple 数据开始 array length 之后计算)
* - tuple[0] 数据
* - typeCode (32 bytes) = 1
* - to (32 bytes)
* - value (32 bytes) = 0
* - data offset (32 bytes) = 128 ( tuple 数据开始计算)
* - data length (32 bytes)
* - data (padded to 32-byte boundary)
*/
private fun encodeProxyTransactionData(safeTx: SafeTransaction): String {
val selector = EthereumUtils.getFunctionSelector("proxy((uint8,address,uint256,bytes)[])")
val callData = safeTx.data.removePrefix("0x")
val dataLen = callData.length / 2
val dataLenPadded = (dataLen + 31) / 32 * 32 * 2
val dataPadded = callData.padEnd(dataLenPadded, '0')
// ABI 编码:tuple 数组,tuple 包含动态类型 bytes
// 1. array offset: 32 (指向 array length)
val arrayOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
// 2. array length: 1
val arrayLength = EthereumUtils.encodeUint256(BigInteger.ONE)
// 3. tuple[0] offset: 32 (指向 tuple 数据开始,从 array length 之后计算)
val tupleOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
// 4. tuple[0] 数据:
// - typeCode: 1
val typeCode = EthereumUtils.encodeUint256(BigInteger.ONE)
// - to: address
val toEncoded = EthereumUtils.encodeAddress(safeTx.to)
// - value: 0
val valueEncoded = EthereumUtils.encodeUint256(BigInteger.ZERO)
// - data offset: 128 (从 tuple 数据开始计算,typeCode+to+value = 3*32 = 96,加上 offset 字段 = 128)
val dataOffsetInTuple = BigInteger.valueOf(128)
val dataOffsetEncoded = EthereumUtils.encodeUint256(dataOffsetInTuple)
// - data length
val dataLengthEncoded = EthereumUtils.encodeUint256(BigInteger.valueOf(dataLen.toLong()))
// - data (padded)
return "0x" + selector.removePrefix("0x") + arrayOffset + arrayLength +
tupleOffset + typeCode + toEncoded + valueEncoded + dataOffsetEncoded +
dataLengthEncoded + dataPadded
}
/**
* 估算 PROXY 交易的 gas limit
* 参考: builder-relayer-client builder/proxy.ts getGasLimit
*/
private suspend fun estimateProxyGasLimit(
from: String,
to: String,
data: String
): String {
val rpcApi = polygonRpcApi
val rpcRequest = JsonRpcRequest(
method = "eth_estimateGas",
params = listOf(
mapOf(
"from" to from,
"to" to to,
"data" to data
)
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
throw Exception("eth_estimateGas 调用失败: ${response.code()} ${response.message()}")
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
throw Exception("eth_estimateGas 返回错误: ${rpcResponse.error.message}")
}
val hexGasLimit = rpcResponse.result?.asString
?: throw Exception("eth_estimateGas 结果为空")
// 将十六进制转换为十进制字符串
val gasLimitBigInt = BigInteger(hexGasLimit.removePrefix("0x"), 16)
return gasLimitBigInt.toString()
}
/**
* 创建 PROXY 结构哈希参考 builder-relayer-client builder/proxy.ts createStructHash
* concat: "rlx:" + from + to + data + txFee + gasPrice + gasLimit + nonce + relayHub + relay, then keccak256
*/
private fun createProxyStructHash(
from: String,
to: String,
data: String,
txFee: String,
gasPrice: String,
gasLimit: String,
nonce: String,
relayHubAddress: String,
relayAddress: String
): ByteArray {
val rlxPrefix = "rlx:".toByteArray(Charsets.UTF_8)
val fromBytes = EthereumUtils.hexToBytes(from.lowercase().removePrefix("0x").padStart(40, '0'))
val toBytes = EthereumUtils.hexToBytes(to.lowercase().removePrefix("0x").padStart(40, '0'))
val dataBytes = EthereumUtils.hexToBytes(data.removePrefix("0x"))
val txFeeBytes = EthereumUtils.encodeUint256(BigInteger(txFee)).let { EthereumUtils.hexToBytes(it) }
val gasPriceBytes = EthereumUtils.encodeUint256(BigInteger(gasPrice)).let { EthereumUtils.hexToBytes(it) }
val gasLimitBytes = EthereumUtils.encodeUint256(BigInteger(gasLimit)).let { EthereumUtils.hexToBytes(it) }
val nonceBytes = EthereumUtils.encodeUint256(BigInteger(nonce)).let { EthereumUtils.hexToBytes(it) }
val relayHubBytes = EthereumUtils.hexToBytes(relayHubAddress.lowercase().removePrefix("0x").padStart(40, '0'))
val relayBytes = EthereumUtils.hexToBytes(relayAddress.lowercase().removePrefix("0x").padStart(40, '0'))
val concat = rlxPrefix + fromBytes + toBytes + dataBytes + txFeeBytes + gasPriceBytes +
gasLimitBytes + nonceBytes + relayHubBytes + relayBytes
return EthereumUtils.keccak256(concat)
}
/**
* 通过 Builder Relayer 执行交易Gasless
* 参考: builder-relayer-client/src/client.ts execute 方法
@@ -278,7 +525,7 @@ class RelayClientService(
val redeemCallData = safeTx.data
// 获取 Proxy 的 nonce(通过 Builder Relayer API
val nonceResponse = relayerApi.getNonce(fromAddress, "SAFE")
val nonceResponse = relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE)
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
@@ -345,7 +592,7 @@ class RelayClientService(
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
val request = BuilderRelayerApi.TransactionRequest(
type = "SAFE",
type = RELAYER_TYPE_SAFE,
from = fromAddress,
to = safeTx.to,
proxyWallet = proxyAddress,
@@ -0,0 +1,97 @@
-- ============================================
-- 回测功能表创建
-- ============================================
-- ============================================
-- 2. 创建回测任务表
-- ============================================
CREATE TABLE IF NOT EXISTS backtest_task (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '回测任务ID',
task_name VARCHAR(100) NOT NULL COMMENT '回测任务名称',
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
initial_balance DECIMAL(20, 8) NOT NULL COMMENT '初始资金',
final_balance DECIMAL(20, 8) DEFAULT NULL COMMENT '最终资金',
profit_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '收益金额',
profit_rate DECIMAL(10, 4) DEFAULT NULL COMMENT '收益率(%)',
backtest_days INT NOT NULL COMMENT '回测天数',
start_time BIGINT NOT NULL COMMENT '回测开始时间(历史时间)',
end_time BIGINT DEFAULT NULL COMMENT '回测结束时间(历史时间)',
-- 跟单配置 (复制CopyTrading表结构)
copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单模式: RATIO/FIXED',
copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '跟单比例',
fixed_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '固定金额',
max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.0 COMMENT '最大单笔订单',
min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '最小单笔订单',
max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.0 COMMENT '最大每日亏损',
max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '最大每日订单数',
price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.0 COMMENT '价格容忍度(%)',
delay_seconds INT NOT NULL DEFAULT 0 COMMENT '延迟秒数',
support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持卖出',
min_order_depth DECIMAL(20, 8) DEFAULT NULL COMMENT '最小订单深度',
max_spread DECIMAL(20, 8) DEFAULT NULL COMMENT '最大价差',
min_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最低价格',
max_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最高价格',
max_position_value DECIMAL(20, 8) DEFAULT NULL COMMENT '最大仓位金额',
keyword_filter_mode VARCHAR(20) NOT NULL DEFAULT 'DISABLED' COMMENT '关键字过滤模式',
keywords JSON DEFAULT NULL COMMENT '关键字列表',
max_market_end_date BIGINT DEFAULT NULL COMMENT '市场截止时间限制',
-- 统计字段
avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)',
data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED',
-- 执行状态
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态: PENDING/RUNNING/COMPLETED/STOPPED/FAILED',
progress INT DEFAULT 0 COMMENT '执行进度(0-100)',
total_trades INT DEFAULT 0 COMMENT '总交易笔数',
buy_trades INT DEFAULT 0 COMMENT '买入笔数',
sell_trades INT DEFAULT 0 COMMENT '卖出笔数',
win_trades INT DEFAULT 0 COMMENT '盈利交易笔数',
loss_trades INT DEFAULT 0 COMMENT '亏损交易笔数',
win_rate DECIMAL(5, 2) DEFAULT NULL COMMENT '胜率(%)',
max_profit DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔盈利',
max_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔亏损',
max_drawdown DECIMAL(20, 8) DEFAULT NULL COMMENT '最大回撤',
error_message TEXT DEFAULT NULL COMMENT '错误信息',
created_at BIGINT NOT NULL COMMENT '创建时间',
execution_started_at BIGINT DEFAULT NULL COMMENT '执行开始时间(系统时间)',
execution_finished_at BIGINT DEFAULT NULL COMMENT '执行完成时间(系统时间)',
updated_at BIGINT NOT NULL COMMENT '更新时间',
INDEX idx_leader_id (leader_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
INDEX idx_leader_profit (leader_id, profit_rate DESC),
INDEX idx_status_created (status, created_at DESC),
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测任务表';
-- ============================================
-- 3. 创建回测交易记录表
-- ============================================
CREATE TABLE IF NOT EXISTS backtest_trade (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '交易记录ID',
backtest_task_id BIGINT NOT NULL COMMENT '回测任务ID',
trade_time BIGINT NOT NULL COMMENT '交易时间',
market_id VARCHAR(100) NOT NULL COMMENT '市场ID',
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
side VARCHAR(20) NOT NULL COMMENT '方向: BUY/SELL/SETTLEMENT',
outcome VARCHAR(50) NOT NULL COMMENT '结果: YES/NO或outcomeIndex',
outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
quantity DECIMAL(20, 8) NOT NULL COMMENT '数量',
price DECIMAL(20, 8) NOT NULL COMMENT '价格',
amount DECIMAL(20, 8) NOT NULL COMMENT '金额',
fee DECIMAL(20, 8) NOT NULL DEFAULT 0.0 COMMENT '手续费',
profit_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '盈亏(仅卖出时)',
balance_after DECIMAL(20, 8) NOT NULL COMMENT '交易后余额',
leader_trade_id VARCHAR(100) DEFAULT NULL COMMENT 'Leader原始交易ID',
created_at BIGINT NOT NULL COMMENT '创建时间',
INDEX idx_backtest_task_id (backtest_task_id),
INDEX idx_trade_time (trade_time),
FOREIGN KEY (backtest_task_id) REFERENCES backtest_task(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测交易记录表';
@@ -0,0 +1,14 @@
-- ============================================
-- 回测功能恢复字段添加
-- ============================================
-- 添加恢复相关字段到回测任务表
ALTER TABLE backtest_task
ADD COLUMN last_processed_trade_time BIGINT DEFAULT NULL COMMENT '最后处理的交易时间(用于中断恢复)',
ADD COLUMN last_processed_trade_index INT DEFAULT 0 COMMENT '最后处理的交易索引(用于中断恢复)',
ADD COLUMN processed_trade_count INT DEFAULT 0 COMMENT '已处理的交易数量(用于显示真实进度)';
-- 添加索引以优化查询性能
ALTER TABLE backtest_task
ADD INDEX idx_last_processed_trade_time (last_processed_trade_time);
@@ -0,0 +1,13 @@
-- Drop unused columns from backtest_task table
-- These fields are not needed for backtest scenarios as they use historical data
-- Note: Using standard SQL syntax compatible with MySQL 5.7+
-- Check if columns exist before dropping (using standard approach)
ALTER TABLE backtest_task DROP COLUMN price_tolerance;
ALTER TABLE backtest_task DROP COLUMN delay_seconds;
ALTER TABLE backtest_task DROP COLUMN min_order_depth;
ALTER TABLE backtest_task DROP COLUMN max_spread;
ALTER TABLE backtest_task DROP COLUMN min_price;
ALTER TABLE backtest_task DROP COLUMN max_price;
ALTER TABLE backtest_task DROP COLUMN max_position_value;
ALTER TABLE backtest_task DROP COLUMN max_market_end_date;
@@ -0,0 +1,15 @@
-- ============================================
-- 修复回测恢复逻辑:将 last_processed_trade_index 默认值改为 NULL
-- ============================================
-- 问题:新建任务的 last_processed_trade_index 默认值为 0,导致被误判为恢复任务
-- 解决:将默认值改为 NULL,并将现有新任务的 0 值改为 NULL
-- 1. 将现有新任务(status='PENDING' 且 last_processed_trade_index=0)的索引值改为 NULL
UPDATE backtest_task
SET last_processed_trade_index = NULL
WHERE status = 'PENDING' AND last_processed_trade_index = 0;
-- 2. 修改字段定义,允许 NULL 并设置默认值为 NULL
ALTER TABLE backtest_task
MODIFY COLUMN last_processed_trade_index INT DEFAULT NULL COMMENT '最后处理的交易索引(用于中断恢复)';
@@ -0,0 +1,3 @@
-- 添加最大仓位金额配置到回测任务表
ALTER TABLE backtest_task
ADD COLUMN max_position_value DECIMAL(20, 8) COMMENT '最大仓位金额(USDC),NULL表示不启用';
@@ -0,0 +1,9 @@
-- ============================================
-- V32: 添加回测价格区间过滤字段
-- 用于配置价格区间,仅在指定价格区间内的订单才会跟单
-- ============================================
-- 添加价格区间字段到回测任务表
ALTER TABLE backtest_task
ADD COLUMN min_price DECIMAL(20, 8) NULL COMMENT '最低价格(可选),NULL表示不限制最低价',
ADD COLUMN max_price DECIMAL(20, 8) NULL COMMENT '最高价格(可选),NULL表示不限制最高价';
@@ -0,0 +1,38 @@
-- ============================================
-- V33: 唯一约束从 wallet_address 改为 proxy_address
-- 允许同一 EOA 以不同代理类型(Magic/Safe)各导入一个账户,按代理地址去重
-- ============================================
-- 将已存在账户的 wallet_type 统一为 safe(历史数据兼容)
UPDATE wallet_accounts SET wallet_type = 'safe';
-- 删除 wallet_address 上的唯一约束(通过 KEY_COLUMN_USAGE 定位到该列的约束名)
SET @uk_name = (SELECT kcu.CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
ON kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA AND kcu.TABLE_NAME = tc.TABLE_NAME AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
WHERE kcu.TABLE_SCHEMA = DATABASE()
AND kcu.TABLE_NAME = 'wallet_accounts'
AND tc.CONSTRAINT_TYPE = 'UNIQUE'
AND kcu.COLUMN_NAME = 'wallet_address'
LIMIT 1);
SET @sql = IF(@uk_name IS NOT NULL,
CONCAT('ALTER TABLE wallet_accounts DROP INDEX ', @uk_name),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 为 proxy_address 添加唯一约束(若已存在则跳过)
SET @uk_exists = (SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'wallet_accounts'
AND CONSTRAINT_TYPE = 'UNIQUE'
AND CONSTRAINT_NAME = 'uk_wallet_accounts_proxy_address'
LIMIT 1);
SET @sql2 = IF(@uk_exists IS NULL,
'ALTER TABLE wallet_accounts ADD UNIQUE KEY uk_wallet_accounts_proxy_address (proxy_address)',
'SELECT 1');
PREPARE stmt2 FROM @sql2;
EXECUTE stmt2;
DEALLOCATE PREPARE stmt2;
@@ -255,3 +255,56 @@ error.server.order_tracking_process_failed=Failed to process order tracking
error.server.order_tracking_buy_failed=Failed to process buy order
error.server.order_tracking_sell_failed=Failed to process sell order
error.server.order_tracking_match_failed=Order matching failed
# Backtest service errors
error.backtest.task_not_found=Backtest task not found
error.backtest.leader_not_found=Leader not found
error.backtest.days_invalid=Backtest days must be between 1-15 days
error.backtest.initial_balance_invalid=Invalid initial balance
error.backtest.task_running=Backtest task is running, cannot delete
error.backtest.task_not_completed=Re-run is only supported for completed backtest tasks
error.server.backtest_create_failed=Failed to create backtest task
error.server.backtest_update_failed=Failed to update backtest task
error.server.backtest_delete_failed=Failed to delete backtest task
error.server.backtest_list_fetch_failed=Failed to fetch backtest list
error.server.backtest_detail_fetch_failed=Failed to fetch backtest detail
error.server.backtest_trades_fetch_failed=Failed to fetch backtest trades
error.server.backtest_execute_failed=Failed to execute backtest
error.server.backtest_historical_data_fetch_failed=Failed to fetch historical data
error.server.backtest_stop_failed=Failed to stop backtest task
error.server.backtest_retry_failed=Failed to retry backtest task
error.server.backtest_rerun_failed=Failed to re-run backtest with same config
# Backtest Management
backtest.title=Backtest Management
backtest.create_task=Create Backtest
backtest.task_name=Task Name
backtest.leader=Leader
backtest.initial_balance=Initial Balance
backtest.backtest_days=Backtest Days
backtest.profit_amount=Profit Amount
backtest.profit_rate=Profit Rate
backtest.backtest_days_range=Backtest Days Range (1-15 days)
backtest.total_trades=Total Trades
backtest.buy_trades=Buy Trades
backtest.sell_trades=Sell Trades
backtest.win_trades=Win Trades
backtest.loss_trades=Loss Trades
backtest.win_rate=Win Rate
backtest.max_profit=Max Profit
backtest.max_loss=Max Loss
backtest.max_drawdown=Max Drawdown
backtest.avg_holding_time=Avg Holding Time
# Backtest Status
backtest.status.pending=Pending
backtest.status.running=Running
backtest.status.completed=Completed
backtest.status.stopped=Stopped
backtest.status.failed=Failed
# Backtest Config
backtest.copy_mode.ratio=Ratio Mode
backtest.copy_mode.fixed=Fixed Amount
backtest.price_tolerance=Price Tolerance
backtest.delay_seconds=Delay Seconds
backtest.support_sell=Support Sell
@@ -255,3 +255,62 @@ error.server.order_tracking_process_failed=处理订单跟踪失败
error.server.order_tracking_buy_failed=处理买入订单失败
error.server.order_tracking_sell_failed=处理卖出订单失败
error.server.order_tracking_match_failed=订单匹配失败
# 回测服务错误
error.backtest.task_not_found=回测任务不存在
error.backtest.leader_not_found=Leader不存在
error.backtest.days_invalid=回测天数必须在 1-15 天之间
error.backtest.initial_balance_invalid=初始金额无效
error.backtest.task_running=回测任务正在运行,无法删除
error.backtest.task_not_completed=仅支持对已完成的回测任务重新测试
error.server.backtest_create_failed=创建回测任务失败
error.server.backtest_update_failed=更新回测任务失败
error.server.backtest_delete_failed=删除回测任务失败
error.server.backtest_list_fetch_failed=查询回测列表失败
error.server.backtest_detail_fetch_failed=查询回测详情失败
error.server.backtest_trades_fetch_failed=查询回测交易记录失败
error.server.backtest_execute_failed=回测执行失败
error.server.backtest_historical_data_fetch_failed=历史数据获取失败
error.server.backtest_stop_failed=停止回测任务失败
error.server.backtest_retry_failed=重试回测任务失败
error.server.backtest_rerun_failed=按配置重新测试失败
# 回测管理
backtest.title=回测管理
backtest.create_task=新增回测
backtest.task_name=回测名称
backtest.leader=Leader
backtest.initial_balance=初始金额
backtest.backtest_days=回测天数
backtest.profit_amount=收益额
backtest.profit_rate=收益率
backtest.backtest_days_range=回测天数范围(1-15天)
backtest.total_trades=总交易笔数
backtest.buy_trades=买入笔数
backtest.sell_trades=卖出笔数
backtest.win_trades=盈利交易笔数
backtest.loss_trades=亏损交易笔数
backtest.win_rate=胜率
backtest.max_profit=最大单笔盈利
backtest.max_loss=最大单笔亏损
backtest.max_drawdown=最大回撤
backtest.avg_holding_time=平均持仓时间
# 回测状态
backtest.status.pending=待执行
backtest.status.running=运行中
backtest.status.completed=已完成
backtest.status.stopped=已停止
backtest.status.failed=失败
# 回测配置
backtest.copy_mode.ratio=比例模式
backtest.copy_mode.fixed=固定金额
backtest.price_tolerance=价格容忍度
backtest.delay_seconds=延迟秒数
backtest.support_sell=支持卖出
# 订单跟踪服务错误
error.server.order_tracking_process_failed=处理订单跟踪失败
error.server.order_tracking_buy_failed=处理买入订单失败
error.server.order_tracking_sell_failed=处理卖出订单失败
error.server.order_tracking_match_failed=订单匹配失败
@@ -255,3 +255,56 @@ error.server.order_tracking_process_failed=處理訂單跟蹤失敗
error.server.order_tracking_buy_failed=處理買入訂單失敗
error.server.order_tracking_sell_failed=處理賣出訂單失敗
error.server.order_tracking_match_failed=訂單匹配失敗
# 回測服務錯誤
error.backtest.task_not_found=回測任務不存在
error.backtest.leader_not_found=Leader不存在
error.backtest.days_invalid=回測天數必須在 1-15 天之間
error.backtest.initial_balance_invalid=初始金額無效
error.backtest.task_running=回測任務正在運行,無法刪除
error.backtest.task_not_completed=僅支援對已完成的回測任務重新測試
error.server.backtest_create_failed=創建回測任務失敗
error.server.backtest_update_failed=更新回測任務失敗
error.server.backtest_delete_failed=刪除回測任務失敗
error.server.backtest_list_fetch_failed=查詢回測列表失敗
error.server.backtest_detail_fetch_failed=查詢回測詳情失敗
error.server.backtest_trades_fetch_failed=查詢回測交易記錄失敗
error.server.backtest_execute_failed=回測執行失敗
error.server.backtest_historical_data_fetch_failed=歷史數據獲取失敗
error.server.backtest_stop_failed=停止回測任務失敗
error.server.backtest_retry_failed=重試回測任務失敗
error.server.backtest_rerun_failed=依配置重新測試失敗
# 回測管理
backtest.title=回測管理
backtest.create_task=新增回測
backtest.task_name=回測名稱
backtest.leader=Leader
backtest.initial_balance=初始金額
backtest.backtest_days=回測天數
backtest.profit_amount=收益額
backtest.profit_rate=收益率
backtest.backtest_days_range=回測天數範圍(1-15天)
backtest.total_trades=總交易筆數
backtest.buy_trades=買入筆數
backtest.sell_trades=賣出筆數
backtest.win_trades=盈利交易筆數
backtest.loss_trades=虧損交易筆數
backtest.win_rate=勝率
backtest.max_profit=最大單筆盈利
backtest.max_loss=最大單筆虧損
backtest.max_drawdown=最大回撤
backtest.avg_holding_time=平均持倉時間
# 回測狀態
backtest.status.pending=待執行
backtest.status.running=運行中
backtest.status.completed=已完成
backtest.status.stopped=已停止
backtest.status.failed=失敗
# 回測配置
backtest.copy_mode.ratio=比例模式
backtest.copy_mode.fixed=固定金額
backtest.price_tolerance=價格容忍度
backtest.delay_seconds=延遲秒數
backtest.support_sell=支持賣出
+194
View File
@@ -0,0 +1,194 @@
# PolyHermes 一键部署脚本使用说明
## ✨ 核心特性
- **可在任意目录运行** - 无需下载项目源码
- **仅使用线上镜像** - 从 Docker Hub 拉取官方镜像
- **自动下载配置** - 从 GitHub 下载最新的 `docker-compose.prod.yml`
- **交互式配置** - 友好的问答式配置向导
- **自动生成密钥** - 所有敏感配置回车自动生成安全随机值
## 🚀 快速开始
### 一键安装(推荐)
**使用 curl(推荐):**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
```
**使用 wget**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
```
这个命令会自动:
- 📁 创建专用工作目录 `~/polyhermes`
- 📥 下载部署脚本
- ✅ 自动检查 Docker 环境
- ⚙️ 交互式配置所有参数(支持回车使用默认值)
- 🔐 自动生成安全的随机密钥
- 🚀 自动下载最新镜像并部署
**或者直接通过管道运行(不保存文件):**
```bash
# curl 方式
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash
# wget 方式
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -qO- https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash
```
### 方式一:直接下载脚本运行
```bash
# 下载脚本
curl -O https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh
# 添加执行权限
chmod +x deploy-interactive.sh
# 运行
./deploy-interactive.sh
```
### 方式二:在项目目录中运行
```bash
git clone https://github.com/WrBug/PolyHermes.git
cd PolyHermes
./deploy-interactive.sh
```
## 📝 使用流程
运行脚本后,会引导你完成以下步骤:
```
步骤 1: 环境检查 → 检查 Docker/Docker Compose
步骤 2: 配置收集 → 交互式输入配置(可全部回车使用默认)
步骤 3: 获取部署配置 → 从 GitHub 下载 docker-compose.prod.yml
步骤 4: 生成环境变量文件 → 自动生成 .env
步骤 5: 拉取 Docker 镜像 → 从 Docker Hub 拉取最新镜像
步骤 6: 部署服务 → 启动容器
步骤 7: 健康检查 → 验证服务是否正常运行
```
## ⚡ 最简单的使用方式
**所有配置项直接回车使用默认值**,脚本会自动:
- 使用端口 80(应用)和 3307MySQL
- 生成 32 字符的数据库密码
- 生成 128 字符的 JWT 密钥
- 生成 64 字符的管理员重置密钥
- 生成 64 字符的加密密钥
- 配置合理的日志级别
### 交互示例
脚本会逐项提示你输入配置,**直接按回车即可跳过使用默认值**:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
步骤 2: 配置收集
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成
⚠ 密钥配置:回车将自动生成安全的随机密钥
⚠ 其他配置:回车将使用括号中的默认值
【基础配置】
将配置:服务器端口、MySQL端口、时区
➤ 服务器端口 [默认: 80]: ⏎
➤ MySQL 端口(外部访问) [默认: 3307]: ⏎
➤ 时区 [默认: Asia/Shanghai]: ⏎
【数据库配置】
将配置:数据库用户名、数据库密码
➤ 数据库用户名 [默认: root]: ⏎
➤ 数据库密码 [回车自动生成]: ⏎
[✓] 已自动生成数据库密码(32字符)
【安全配置】
将配置:JWT密钥、管理员密码重置密钥、数据加密密钥
➤ JWT 密钥 [回车自动生成]: ⏎
[✓] 已自动生成 JWT 密钥(128字符)
➤ 管理员密码重置密钥 [回车自动生成]: ⏎
[✓] 已自动生成管理员重置密钥(64字符)
➤ 加密密钥(用于加密 API Key) [回车自动生成]: ⏎
[✓] 已自动生成加密密钥(64字符)
【日志配置】
将配置:Root日志级别、应用日志级别
可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF
➤ Root 日志级别(第三方库) [默认: WARN]: ⏎
➤ 应用日志级别 [默认: INFO]: ⏎
【其他配置】
将配置:运行环境、自动更新策略、GitHub仓库
➤ Spring Profile [默认: prod]: ⏎
➤ 允许预发布版本更新 (true/false) [默认: false]: ⏎
➤ GitHub 仓库 [默认: WrBug/PolyHermes]: ⏎
```
## 🔧 脚本生成的文件
脚本运行后会在当前目录生成:
1. **docker-compose.prod.yml** - 从 GitHub 下载的 Docker Compose 配置文件(始终保持最新)
2. **.env** - 根据你的配置自动生成的环境变量文件
这两个文件包含了运行 PolyHermes 所需的全部配置。
## 🌐 部署后管理
### 快速更新(推荐)
如果已有配置文件,再次运行脚本时会自动检测并询问:
```bash
./deploy-interactive.sh
```
```
【检测到现有配置】
发现已存在的 .env 配置文件
是否使用现有配置直接更新镜像?[Y/n]: ⏎
```
- **回车或输入 Y**:使用现有配置,直接拉取最新镜像并更新
- **输入 N**:重新配置(会备份现有配置)
### 手动管理命令
```bash
# 查看服务状态
docker compose -f docker-compose.prod.yml ps
# 查看日志
docker compose -f docker-compose.prod.yml logs -f
# 重启服务
docker compose -f docker-compose.prod.yml restart
# 停止服务
docker compose -f docker-compose.prod.yml down
# 更新到最新版本
docker pull wrbug/polyhermes:latest
docker compose -f docker-compose.prod.yml up -d
```
## 🔐 安全建议
- **保护 .env 文件**:其中包含敏感信息,切勿提交到版本控制
- **定期备份数据库**:数据存储在 Docker volume `mysql-data`
- **生产环境配置 HTTPS**:建议使用 Nginx 或 Caddy 作为反向代理
## 📞 获取支持
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
- [问题反馈](https://github.com/WrBug/PolyHermes/issues)
- [完整部署文档](docs/zh/DEPLOYMENT_GUIDE.md)
+510
View File
@@ -0,0 +1,510 @@
#!/bin/bash
# ========================================
# PolyHermes 交互式一键部署脚本
# ========================================
# 功能:
# - 交互式配置环境变量
# - 自动生成安全密钥
# - 使用 Docker Hub 线上镜像部署
# - 支持配置预检和回滚
# ========================================
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 打印函数
info() {
echo -e "${GREEN}[✓]${NC} $1"
}
warn() {
echo -e "${YELLOW}[⚠]${NC} $1"
}
error() {
echo -e "${RED}[✗]${NC} $1"
}
title() {
echo -e "${CYAN}${1}${NC}"
}
# 生成随机密钥
generate_secret() {
local length=${1:-32}
if command -v openssl &> /dev/null; then
openssl rand -hex $length
else
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w $((length * 2)) | head -n 1
fi
}
# 生成随机端口号(10000-60000之间)
generate_random_port() {
echo $((10000 + RANDOM % 50001))
}
# 读取用户输入(支持默认值)
read_input() {
local prompt="$1"
local default="$2"
local is_secret="$3"
local value=""
# 构建提示信息(不使用颜色,因为 read -p 可能不支持)
local prompt_text=""
if [ -n "$default" ]; then
if [ "$is_secret" = "secret" ]; then
prompt_text="${prompt} [回车自动生成]: "
else
prompt_text="${prompt} [默认: ${default}]: "
fi
else
prompt_text="${prompt}: "
fi
# 使用 read -p 确保提示正确显示
read -r -p "$prompt_text" value
# 如果用户没有输入,使用默认值
if [ -z "$value" ]; then
if [ "$is_secret" = "secret" ] && [ -z "$default" ]; then
# 自动生成密钥
case "$prompt" in
*JWT*)
value=$(generate_secret 64)
# 输出到 stderr,避免被捕获到返回值中
info "已自动生成 JWT 密钥(128字符)" >&2
;;
*管理员*|*ADMIN*)
value=$(generate_secret 32)
info "已自动生成管理员重置密钥(64字符)" >&2
;;
*加密*|*CRYPTO*)
value=$(generate_secret 32)
info "已自动生成加密密钥(64字符)" >&2
;;
*数据库密码*|*DB_PASSWORD*)
value=$(generate_secret 16)
info "已自动生成数据库密码(32字符)" >&2
;;
*)
value="$default"
;;
esac
else
value="$default"
fi
fi
echo "$value"
}
# 检查 Docker 环境
check_docker() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 1: 环境检查"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 检查 Docker
if ! command -v docker &> /dev/null; then
error "Docker 未安装"
echo ""
info "请先安装 Docker"
info " macOS: brew install docker"
info " Ubuntu/Debian: apt-get install docker.io"
info " CentOS/RHEL: yum install docker"
exit 1
fi
info "Docker 已安装: $(docker --version | head -1)"
# 检查 Docker Compose
if docker compose version &> /dev/null 2>&1; then
info "Docker Compose 已安装: $(docker compose version)"
elif command -v docker-compose &> /dev/null; then
info "Docker Compose 已安装: $(docker-compose --version)"
else
error "Docker Compose 未安装"
echo ""
info "请先安装 Docker Compose"
info " https://docs.docker.com/compose/install/"
exit 1
fi
# 检查 Docker 守护进程
if ! docker info &> /dev/null; then
error "Docker 守护进程未运行"
info "请启动 Docker 服务:"
info " macOS: 打开 Docker Desktop"
info " Linux: systemctl start docker"
exit 1
fi
info "Docker 守护进程运行正常"
echo ""
}
# 交互式配置收集
collect_configuration() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 2: 配置收集"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
info "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成"
echo ""
warn "密钥配置:回车将自动生成安全的随机密钥"
warn "其他配置:回车将使用括号中的默认值"
echo ""
# 基础配置
title "【基础配置】"
echo -e "${CYAN}将配置:服务器端口、MySQL端口、时区${NC}"
# 生成随机端口作为默认值
DEFAULT_PORT=$(generate_random_port)
SERVER_PORT=$(read_input "➤ 服务器端口" "$DEFAULT_PORT")
MYSQL_PORT=$(read_input "➤ MySQL 端口(外部访问)" "3307")
TZ=$(read_input "➤ 时区" "Asia/Shanghai")
echo ""
# 数据库配置
title "【数据库配置】"
echo -e "${CYAN}将配置:数据库用户名、数据库密码${NC}"
echo -e "${YELLOW}💡 提示:密码留空将自动生成 32 字符的安全随机密码${NC}"
DB_USERNAME=$(read_input "➤ 数据库用户名" "root")
DB_PASSWORD=$(read_input "➤ 数据库密码" "" "secret")
echo ""
# 安全配置
title "【安全配置】"
echo -e "${CYAN}将配置:JWT密钥、管理员密码重置密钥、数据加密密钥${NC}"
echo -e "${YELLOW}💡 提示:留空将自动生成高强度随机密钥(推荐)${NC}"
JWT_SECRET=$(read_input "➤ JWT 密钥" "" "secret")
ADMIN_RESET_PASSWORD_KEY=$(read_input "➤ 管理员密码重置密钥" "" "secret")
CRYPTO_SECRET_KEY=$(read_input "➤ 加密密钥(用于加密 API Key" "" "secret")
echo ""
# 日志配置
title "【日志配置】"
echo -e "${CYAN}将配置:Root日志级别、应用日志级别${NC}"
echo -e "${YELLOW}可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF${NC}"
LOG_LEVEL_ROOT=$(read_input "➤ Root 日志级别(第三方库)" "WARN")
LOG_LEVEL_APP=$(read_input "➤ 应用日志级别" "INFO")
echo ""
# 自动设置不需要用户输入的配置
SPRING_PROFILES_ACTIVE="prod"
ALLOW_PRERELEASE="false"
GITHUB_REPO="WrBug/PolyHermes"
}
# 下载 docker-compose.prod.yml(如果不存在)
download_docker_compose_file() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 3: 获取部署配置"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ -f "docker-compose.prod.yml" ]; then
info "检测到现有 docker-compose.prod.yml,跳过下载"
echo ""
return 0
fi
info "正在从 GitHub 下载 docker-compose.prod.yml..."
# GitHub raw 文件链接
local compose_url="https://raw.githubusercontent.com/WrBug/PolyHermes/main/docker-compose.prod.yml"
# 尝试下载
if curl -fsSL "$compose_url" -o docker-compose.prod.yml; then
info "docker-compose.prod.yml 下载成功"
else
error "docker-compose.prod.yml 下载失败"
warn "请检查网络连接或手动下载:"
warn " $compose_url"
exit 1
fi
echo ""
}
# 生成 .env 文件
generate_env_file() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 4: 生成环境变量文件"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 备份现有 .env 文件
if [ -f ".env" ]; then
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
cp .env "$BACKUP_FILE"
warn "已备份现有配置文件到: $BACKUP_FILE"
fi
# 生成新的 .env 文件
cat > .env <<EOF
# ========================================
# PolyHermes 生产环境配置
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
# ========================================
# ============================================
# 基础配置
# ============================================
TZ=${TZ}
SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE}
SERVER_PORT=${SERVER_PORT}
MYSQL_PORT=${MYSQL_PORT}
# ============================================
# 数据库配置
# ============================================
DB_URL=jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
DB_USERNAME=${DB_USERNAME}
DB_PASSWORD=${DB_PASSWORD}
# ============================================
# 安全配置(请妥善保管)
# ============================================
JWT_SECRET=${JWT_SECRET}
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY}
CRYPTO_SECRET_KEY=${CRYPTO_SECRET_KEY}
# ============================================
# 日志配置
# ============================================
LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT}
LOG_LEVEL_APP=${LOG_LEVEL_APP}
# ============================================
# 其他配置
# ============================================
ALLOW_PRERELEASE=${ALLOW_PRERELEASE}
GITHUB_REPO=${GITHUB_REPO}
EOF
info "配置文件已生成: .env"
echo ""
# 显示配置摘要
title "【配置摘要】"
echo " 服务器端口: ${SERVER_PORT}"
echo " MySQL 端口: ${MYSQL_PORT}"
echo " 时区: ${TZ}"
echo " 数据库用户: ${DB_USERNAME}"
echo " 数据库密码: ${DB_PASSWORD:0:8}... (已隐藏)"
echo " JWT 密钥: ${JWT_SECRET:0:16}... (已隐藏)"
echo " 管理员重置密钥: ${ADMIN_RESET_PASSWORD_KEY:0:16}... (已隐藏)"
echo " 加密密钥: ${CRYPTO_SECRET_KEY:0:16}... (已隐藏)"
echo " 日志级别: Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}"
echo ""
}
# 拉取镜像
pull_images() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 5: 拉取 Docker 镜像"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
info "正在从 Docker Hub 拉取最新镜像..."
# 拉取应用镜像
if docker pull wrbug/polyhermes:latest; then
info "应用镜像拉取成功: wrbug/polyhermes:latest"
else
error "应用镜像拉取失败"
warn "可能的原因:"
warn " 1. 网络连接问题"
warn " 2. Docker Hub 服务异常"
warn " 3. 镜像不存在"
exit 1
fi
# 拉取 MySQL 镜像
if docker pull mysql:8.2; then
info "MySQL 镜像拉取成功: mysql:8.2"
else
warn "MySQL 镜像拉取失败,将在启动时自动下载"
fi
echo ""
}
# 部署服务
deploy_services() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 6: 部署服务"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 停止现有服务
if docker compose -f docker-compose.prod.yml ps -q 2>/dev/null | grep -q .; then
warn "检测到正在运行的服务,正在停止..."
docker compose -f docker-compose.prod.yml down
info "已停止现有服务"
fi
# 启动服务
info "正在启动服务..."
if docker compose -f docker-compose.prod.yml up -d; then
info "服务启动成功"
else
error "服务启动失败"
error "请检查日志: docker compose -f docker-compose.prod.yml logs"
exit 1
fi
echo ""
}
# 健康检查
health_check() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 7: 健康检查"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
info "等待服务启动(最多等待 60 秒)..."
local max_attempts=12
local attempt=0
while [ $attempt -lt $max_attempts ]; do
attempt=$((attempt + 1))
# 检查容器状态
if docker compose -f docker-compose.prod.yml ps | grep -q "Up"; then
info "容器运行正常"
# 检查应用是否响应
if curl -s -o /dev/null -w "%{http_code}" http://localhost:${SERVER_PORT} | grep -q "200\|302\|401"; then
info "应用响应正常"
echo ""
return 0
fi
fi
echo -n "."
sleep 5
done
echo ""
warn "健康检查超时,请手动检查服务状态"
warn "查看日志: docker compose -f docker-compose.prod.yml logs -f"
echo ""
}
# 显示部署信息
show_deployment_info() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 部署完成!"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
info "访问地址: ${GREEN}http://localhost:${SERVER_PORT}${NC}"
echo ""
title "【常用命令】"
echo -e " 查看服务状态: ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}"
echo -e " 查看日志: ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}"
echo -e " 停止服务: ${CYAN}docker compose -f docker-compose.prod.yml down${NC}"
echo -e " 重启服务: ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}"
echo -e " 更新镜像: ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}"
echo ""
title "【数据库连接信息】"
echo -e " 主机: ${CYAN}localhost${NC}"
echo -e " 端口: ${CYAN}${MYSQL_PORT}${NC}"
echo -e " 数据库: ${CYAN}polyhermes${NC}"
echo -e " 用户名: ${CYAN}${DB_USERNAME}${NC}"
echo -e " 密码: ${CYAN}${DB_PASSWORD}${NC}"
echo ""
title "【管理员重置密钥】"
echo -e " 重置密钥: ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}"
echo -e " ${YELLOW}💡 此密钥用于重置管理员密码,请妥善保管${NC}"
echo ""
warn "重要提示:"
warn " 1. 请妥善保管 .env 文件,勿提交到版本控制系统"
warn " 2. 定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data"
warn " 3. 生产环境建议配置反向代理(如 Nginx)并启用 HTTPS"
echo ""
}
# 主函数
main() {
clear
echo ""
title "========================================="
title " PolyHermes 交互式一键部署脚本 "
title "========================================="
echo ""
# 执行部署流程
check_docker
# 检查是否已存在 .env 文件
if [ -f ".env" ]; then
echo ""
title "【检测到现有配置】"
info "发现已存在的 .env 配置文件"
echo ""
echo -ne "${YELLOW}是否使用现有配置直接更新镜像?[Y/n]: ${NC}"
read -r use_existing
use_existing=${use_existing:-Y}
if [[ "$use_existing" =~ ^[Yy]$ ]]; then
info "将使用现有配置,跳过配置步骤"
echo ""
# 从现有 .env 文件读取必要的变量
source .env 2>/dev/null || true
else
warn "将重新配置,现有配置将被备份"
echo ""
collect_configuration
fi
else
collect_configuration
fi
download_docker_compose_file
# 只有在重新配置时才生成新的 .env 文件
if [[ ! "$use_existing" =~ ^[Yy]$ ]] || [ ! -f ".env" ]; then
generate_env_file
fi
# 确认部署
echo ""
title "【确认部署】"
echo -ne "${YELLOW}是否开始部署?[Y/n](回车默认为是): ${NC}"
read -r confirm
# 默认为 Y,只有明确输入 n/N 才取消
confirm=${confirm:-Y}
if [[ "$confirm" =~ ^[Nn]$ ]]; then
warn "部署已取消"
exit 0
fi
echo ""
pull_images
deploy_services
health_check
show_deployment_info
info "部署流程已完成!"
}
# 捕获 Ctrl+C
trap 'echo ""; warn "部署已中断"; exit 1' INT
# 运行主函数
main "$@"
+335
View File
@@ -0,0 +1,335 @@
# PolyHermes 一键部署指南
本文档介绍如何使用交互式一键部署脚本快速部署 PolyHermes 应用。
## 📋 前置要求
### 必需软件
- **Docker**: 版本 20.10 或更高
- **Docker Compose**: 版本 2.0 或更高(或 `docker-compose` v1.29+
### 环境准备
```bash
# macOS 安装 Docker
brew install docker
# Ubuntu/Debian 安装 Docker
curl -fsSL https://get.docker.com | sh
# CentOS/RHEL 安装 Docker
yum install docker-ce docker-ce-cli containerd.io
```
## 🚀 快速开始
### 1. 克隆项目(如果尚未克隆)
```bash
git clone https://github.com/WrBug/PolyHermes.git
cd PolyHermes
```
### 2. 运行部署脚本
```bash
./deploy-interactive.sh
```
### 3. 按提示配置
脚本会引导你完成以下配置:
#### 基础配置
- **服务器端口**:应用对外暴露的端口(默认:80
- **MySQL 端口**:数据库外部访问端口(默认:3307,避免与本地 MySQL 冲突)
- **时区**:服务器时区(默认:Asia/Shanghai
#### 数据库配置
- **数据库用户名**:默认为 `root`
- **数据库密码**:回车自动生成 32 字符随机密码
#### 安全配置(关键)
- **JWT 密钥**:回车自动生成 128 字符随机密钥
- **管理员密码重置密钥**:回车自动生成 64 字符随机密钥
- **加密密钥**:用于加密 API Key,回车自动生成 64 字符随机密钥
#### 日志配置
- **Root 日志级别**:第三方库日志级别(默认:WARN
- 可选:`TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `OFF`
- **应用日志级别**:应用代码日志级别(默认:INFO
#### 其他配置
- **Spring Profile**:环境配置(默认:prod
- **允许预发布版本**:是否允许自动更新到预发布版本(默认:false)
- **GitHub 仓库**:项目仓库地址(默认:WrBug/PolyHermes
### 4. 确认并部署
配置完成后,脚本会:
1. 生成 `.env` 配置文件
2. 显示配置摘要
3. 请求确认部署
4. 拉取 Docker Hub 镜像
5. 启动服务
6. 执行健康检查
## 📝 配置示例
### 使用默认值部署(推荐)
所有配置项直接回车,脚本会自动生成安全的随机密钥:
```
服务器端口 [默认: 80]: ⏎
MySQL 端口(外部访问) [默认: 3307]: ⏎
时区 [默认: Asia/Shanghai]: ⏎
数据库用户名 [默认: root]: ⏎
数据库密码 [回车自动生成]: ⏎
JWT 密钥 [回车自动生成]: ⏎
管理员密码重置密钥 [回车自动生成]: ⏎
加密密钥(用于加密 API Key) [回车自动生成]: ⏎
Root 日志级别(第三方库) [默认: WARN]: ⏎
应用日志级别 [默认: INFO]: ⏎
Spring Profile [默认: prod]: ⏎
允许预发布版本更新 [默认: false]: ⏎
GitHub 仓库 [默认: WrBug/PolyHermes]: ⏎
```
### 自定义端口部署
如果需要使用不同的端口:
```
服务器端口 [默认: 80]: 8080⏎
MySQL 端口(外部访问) [默认: 3307]: 33306⏎
```
### 开发环境部署
启用 DEBUG 日志:
```
Root 日志级别(第三方库) [默认: WARN]: DEBUG⏎
应用日志级别 [默认: INFO]: DEBUG⏎
```
## 🔧 部署后管理
### 访问应用
部署完成后,访问:
```
http://localhost:80
```
(或你配置的自定义端口)
### 常用命令
```bash
# 查看服务状态
docker compose -f docker-compose.prod.yml ps
# 查看实时日志
docker compose -f docker-compose.prod.yml logs -f
# 仅查看应用日志
docker compose -f docker-compose.prod.yml logs -f app
# 停止服务
docker compose -f docker-compose.prod.yml down
# 重启服务
docker compose -f docker-compose.prod.yml restart
# 更新到最新版本
docker pull wrbug/polyhermes:latest
docker compose -f docker-compose.prod.yml up -d
```
### 数据库连接
使用配置的凭据连接到 MySQL
```bash
mysql -h 127.0.0.1 -P 3307 -u root -p
# 输入你在部署时设置的数据库密码
```
或使用图形化工具(如 DBeaver、Navicat):
- **主机**: `localhost`
- **端口**: `3307`(或你配置的端口)
- **数据库**: `polyhermes`
- **用户名**: `root`(或你配置的用户名)
- **密码**: 部署时设置的密码(可在 `.env` 文件中查看)
## 🔐 安全最佳实践
### 保护配置文件
```bash
# 设置 .env 文件权限
chmod 600 .env
# 确保 .env 已添加到 .gitignore
echo ".env" >> .gitignore
```
### 定期更换密钥
生产环境建议定期更换安全密钥:
```bash
# 生成新的 JWT 密钥(128字符)
openssl rand -hex 64
# 生成新的管理员重置密钥(64字符)
openssl rand -hex 32
# 更新 .env 文件后重启服务
docker compose -f docker-compose.prod.yml restart
```
### 备份数据库
```bash
# 备份数据库
docker exec polyhermes-mysql mysqldump -u root -p polyhermes > backup_$(date +%Y%m%d).sql
# 恢复数据库
docker exec -i polyhermes-mysql mysql -u root -p polyhermes < backup_20260201.sql
```
## 🌐 生产环境部署建议
### 1. 使用反向代理
建议使用 Nginx 或 Caddy 作为反向代理:
```nginx
# Nginx 配置示例
server {
listen 443 ssl http2;
server_name polyhermes.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 2. 配置防火墙
```bash
# UFW (Ubuntu)
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
# Firewalld (CentOS)
firewall-cmd --add-service=http --permanent
firewall-cmd --add-service=https --permanent
firewall-cmd --reload
```
### 3. 启用自动更新
配置定时任务自动检查并更新镜像:
```bash
# 创建更新脚本
cat > /opt/polyhermes-update.sh <<'EOF'
#!/bin/bash
cd /path/to/PolyHermes
docker pull wrbug/polyhermes:latest
docker compose -f docker-compose.prod.yml up -d
EOF
chmod +x /opt/polyhermes-update.sh
# 添加到 crontab(每天凌晨 3 点检查更新)
echo "0 3 * * * /opt/polyhermes-update.sh >> /var/log/polyhermes-update.log 2>&1" | crontab -
```
## 🐛 故障排查
### 服务无法启动
```bash
# 查看详细错误日志
docker compose -f docker-compose.prod.yml logs
# 检查容器状态
docker compose -f docker-compose.prod.yml ps
```
### 数据库连接失败
```bash
# 检查 MySQL 容器状态
docker logs polyhermes-mysql
# 测试数据库连接
docker exec polyhermes-mysql mysql -u root -p -e "SELECT 1"
```
### 端口被占用
```bash
# 查找占用端口的进程
lsof -i :80
# 或
netstat -tulpn | grep :80
# 修改 SERVER_PORT 环境变量
vim .env # 修改 SERVER_PORT=8080
docker compose -f docker-compose.prod.yml up -d
```
### 镜像拉取失败
如果 Docker Hub 访问受限,可以配置镜像加速器:
```bash
# 配置 Docker 镜像加速器
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": [
"https://docker.mirrors.ustc.edu.cn",
"https://hub-mirror.c.163.com"
]
}
EOF
sudo systemctl restart docker
```
## 📚 更多资源
- [项目 README](../../README.md)
- [发布日志](../../RELEASE.md)
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
- [问题反馈](https://github.com/WrBug/PolyHermes/issues)
## 📞 获取帮助
如遇到问题,请:
1. 查看上方的**故障排查**章节
2. 检查 [GitHub Issues](https://github.com/WrBug/PolyHermes/issues)
3. 提交新的 Issue 并附上日志输出
---
**祝部署顺利!** 🎉
+323
View File
@@ -0,0 +1,323 @@
# 跟单回测功能产品需求文档 (PRD)
## 一、功能概述
跟单回测功能允许用户对历史数据进行模拟跟单交易,评估不同跟单策略的收益表现,帮助用户在实际投入资金前验证策略的有效性。
## 二、用户故事
### 主要用户场景
1. **作为用户**,我希望能够创建回测任务,选择特定的 Leader 和跟单配置,以便评估在过去一段时间内使用该策略的收益情况
2. **作为用户**,我希望能够查看所有历史回测结果,并按收益额或收益率排序,以便找到最优策略
3. **作为用户**,我希望能够按 Leader 筛选回测记录,以便比较不同 Leader 的表现
4. **作为用户**,我希望回测结果能够展示详细的交易记录和收益变化,以便深入分析策略表现
5. **作为用户**,我希望回测在资金不足时能够自动停止,模拟真实交易场景
## 三、功能需求
### 3.1 回测管理页面
#### 3.1.1 页面布局
- **页面位置**: 在跟单管理模块下新增"回测管理"菜单项
- **页面标题**: "回测管理" / "Backtest Management"
#### 3.1.2 列表功能
**筛选功能**:
- Leader 筛选下拉框,支持按 Leader 过滤回测记录
- 状态筛选: 全部 / 运行中 / 已完成 / 已停止
**排序功能**:
- 按收益额排序 (升序/降序)
- 按收益率排序 (升序/降序)
- 按创建时间排序 (默认降序)
**列表字段**:
| 字段名 | 说明 | 示例 |
|-------|------|------|
| 回测ID | 唯一标识 | #12345 |
| 配置名称 | 回测任务名称 | "激进策略-Leader A" |
| Leader名称 | 跟单的Leader | "Smart Trader" |
| 初始金额 | 回测起始资金 | $1000 |
| 最终金额 | 回测结束时资金 | $1250 |
| 收益额 | 最终金额 - 初始金额 | +$250 |
| 收益率 | (收益额/初始金额) × 100% | +25% |
| 回测天数 | 回测的时间跨度 | 30天 |
| 交易笔数 | 回测期间执行的交易数量 | 45笔 |
| 状态 | 运行中/已完成/已停止 | 已完成 |
| 开始时间 | 回测开始时间 | 2026-01-01 10:00 |
| 结束时间 | 回测结束时间 | 2026-01-31 15:30 |
| 操作 | 查看详情/删除 | - |
#### 3.1.3 列表操作
- **查看详情**: 点击后展开详细信息,包括:
- 回测配置参数
- 详细交易记录
- 资金变化曲线图
- 收益统计
- **删除**: 删除回测记录(确认后不可恢复)
### 3.2 新增回测任务
#### 3.2.1 创建入口
- 列表页面右上角"新增回测"按钮
- 点击后弹出创建对话框或跳转到创建页面
#### 3.2.2 配置表单
**基本配置**:
- **回测名称** (必填): 用户自定义名称,方便识别
- **选择Leader** (必填): 下拉框选择已添加的Leader
- **初始投入金额** (必填): 模拟起始资金,单位USDC,范围: 1 - 1,000,000
- **回测天数** (必填): 选择回测的历史天数,范围: 1 - 30天
**跟单配置** (参照现有跟单配置参数):
| 配置项 | 字段名 | 说明 | 默认值 |
|-------|-------|------|-------|
| 跟单模式 | copyMode | RATIO(比例模式) / FIXED(固定金额) | RATIO |
| 跟单比例 | copyRatio | 比例模式下生效,相对Leader订单金额的比例 | 1.0 |
| 固定金额 | fixedAmount | 固定金额模式下生效,每笔固定投入金额 | null |
| 最大单笔订单 | maxOrderSize | 单笔订单最大金额限制 | 1000 |
| 最小单笔订单 | minOrderSize | 单笔订单最小金额限制 | 1 |
| 最大每日亏损 | maxDailyLoss | 每日最大亏损限制 | 10000 |
| 最大每日订单数 | maxDailyOrders | 每日最大订单数量限制 | 100 |
| 价格容忍度 | priceTolerance | 价格偏差容忍百分比 | 5% |
| 延迟秒数 | delaySeconds | 跟单延迟时间 | 0 |
| 支持卖出 | supportSell | 是否跟随卖出 | true |
| 最小订单深度 | minOrderDepth | 订单簿深度要求 | null |
| 最大价差 | maxSpread | 买卖价差限制 | null |
| 最低价格 | minPrice | 最低价格限制 | null |
| 最高价格 | maxPrice | 最高价格限制 | null |
| 最大仓位金额 | maxPositionValue | 最大持仓总金额 | null |
| 关键字过滤模式 | keywordFilterMode | DISABLED/WHITELIST/BLACKLIST | DISABLED |
| 关键字列表 | keywords | 关键字数组 | [] |
| 市场截止时间限制 | maxMarketEndDate | 市场结束时间限制 | null |
> [!IMPORTANT]
> 跟单配置表单应完全复用现有的跟单配置组件,保持参数一致性
#### 3.2.3 表单验证
- 回测名称: 不能为空,长度1-100字符
- Leader: 必须选择有效的Leader
- 初始金额: 必须大于0
- 回测天数: 必须在1-30之间
- 其他配置参数: 遵循现有跟单配置的验证规则
#### 3.2.4 提交逻辑
1. 表单验证通过后,提交到后端API
2. 后端保存回测任务到数据库,状态设置为"待执行"
3. 前端显示创建成功提示,自动跳转到列表页面
4. 回测任务由后端轮询服务自动获取并执行
### 3.3 回测详情页面
#### 3.3.1 页面布局
**顶部概览卡片**:
- 回测名称
- Leader信息
- 初始金额 / 最终金额
- 收益额 / 收益率
- 回测时间范围
- 总交易笔数
- 状态
**资金变化图表**:
- X轴: 时间
- Y轴: 账户余额
- 折线图展示资金随时间的变化
**交易记录表格**:
| 时间 | 市场 | 方向 | 数量 | 价格 | 金额 | 盈亏 | 余额 |
|-----|------|-----|------|------|------|------|------|
| 2026-01-01 10:05 | BTC > $100k | 买入 YES | 100 | 0.65 | 65 | - | 935.00 |
| 2026-01-01 14:20 | BTC > $100k | 卖出 YES | 100 | 0.72 | 72 | +7.00 | 942.00 |
| 2026-01-05 16:00 | ETH > $5k | 买入 YES | 50 | 0.80 | 40 | - | 902.00 |
| 2026-01-10 00:00 | ETH > $5k | 市场结算 YES | 50 | 1.00 | 50 | +10.00 | 952.00 |
> [!NOTE]
> **交易类型说明**:
> - **买入**: 跟随Leader买入
> - **卖出**: 跟随Leader卖出
> - **市场结算**: 市场到期自动结算(赎回)
>
> **手续费**: 回测不计算手续费,简化计算逻辑
**统计数据**:
- 总交易笔数
- 买入笔数 / 卖出笔数
- 胜率 (盈利交易 / 总交易)
- 平均收益
- 最大单笔盈利
- 最大单笔亏损
- 最大回撤
## 四、业务规则
### 4.1 回测执行规则
#### 4.1.1 资金检查
- **停止条件**: 当账户余额 < $1 **且无任何持仓**时,回测自动停止
- **继续条件**: 如果余额 < $1 但仍有持仓,继续处理后续交易
- 原因: Leader 可能卖出,或市场到期结算,释放资金
- 处理: 跳过无法执行的买入订单,继续处理卖出和结算
- **订单检查**: 每次下单前检查余额是否充足
- **不足处理**: 余额不足时跳过该买入订单,记录日志,继续监听后续交易
> [!IMPORTANT]
> 只要有持仓存在,就不应停止回测,因为后续可能通过卖出或市场结算回收资金
#### 4.1.2 历史数据获取
- 从 Polymarket API 获取 Leader 的历史交易记录
- 根据回测天数计算起始时间: `startTime = now - (backtestDays × 24 × 3600 × 1000)`
- 按时间顺序回放交易
#### 4.1.3 交易执行模拟
- 按照配置的跟单规则计算跟单金额
- 应用所有过滤条件 (价格、深度、关键字等)
- 模拟订单成交(不计算手续费)
- 更新账户余额
> [!NOTE]
> 回测不计算手续费,简化计算逻辑,避免过于复杂的精度问题
#### 4.1.3.1 余额不足的处理 ⚠️
**场景说明**:
- 当前余额不足以执行买入订单
- 但有持仓未卖出(相当于"待赎回资产")
**处理策略**:
**方案A: 严格模式**(推荐)
- ✅ 仅使用当前可用余额(`currentBalance`
- ✅ 余额不足时跳过该订单,不考虑持仓价值
- ✅ 理由: 更保守,模拟真实场景(持仓未卖出前资金不可用)
**方案B: 宽松模式**(可选)
- 计算"潜在可用资金" = `currentBalance + 持仓市值`
- 允许"透支"买入,后续通过卖出或结算平衡
- 风险: 可能产生不切实际的回测结果
**推荐实现**:
```kotlin
// 严格检查余额
if (totalCost > currentBalance) {
logger.info("余额不足以执行买入订单: 需要 $totalCost, 可用 $currentBalance")
logger.debug("当前持仓价值: ${calculatePositionValue(positions)}, 但不计入可用余额")
continue // 跳过该订单
}
```
**特殊情况: 市场即将结算**
- 如果持仓市场在接下来很短时间内会结算,可以提前释放资金
- 实现: 在每次交易前先执行市场结算检查(已在4.1.5实现)
> [!IMPORTANT]
> 采用**严格模式**更符合真实跟单场景,避免回测结果过于乐观
#### 4.1.4 卖出跟随
- 如果 `supportSell = true`,跟随 Leader 的卖出操作
- 按照买入时的比例卖出持仓
- 计算盈亏并更新余额
#### 4.1.5 市场结算处理 ⭐
- **触发时机**:
- **实时检查**: 在处理每笔Leader交易前,检查所有持仓市场的`endDate`
- **到期即结算**: 如果市场结束时间 ≤ 当前交易时间,立即结算该持仓
- **兜底处理**: 回测结束时,结算所有剩余持仓
- **结算规则**:
- 获取市场最终结果 (通过Polymarket API)
- 持仓方向为胜出方: 按 **1.0** 价格结算
- 持仓方向为失败方: 按 **0.0** 价格结算
- 市场未结算或无法获取结果: 按**成本价**结算 (保守估计)
- **资金流转**: 结算后的资金立即计入余额,可用于后续交易
- **交易记录**: 生成"市场结算"类型的交易记录,用于详情展示
> [!IMPORTANT]
> **实时结算的优势**:
> - ✅ 模拟真实场景: 市场结束时资金会自动返还
> - ✅ 提高资金利用率: 结算后的资金可以参与后续交易
> - ✅ 更准确的收益计算: 反映实际的资金周转情况
### 4.2 数据持久化
#### 4.2.1 回测任务表
- 保存回测基本信息和配置
- 记录执行状态和结果
#### 4.2.2 回测交易记录表
- 保存每笔模拟交易的详细信息
- 用于详情页面展示和分析
### 4.3 并发控制
- 同一时间最多支持 5 个回测任务并发执行
- 新任务排队等待,FIFO策略
- 前端显示任务队列位置
## 五、UI/UX 要求
### 5.1 响应式设计
- 支持桌面和移动端浏览
- 表格在小屏幕上支持横向滚动
### 5.2 国际化
- 支持中文和英文
- 所有文案提供双语版本
### 5.3 交互体验
- 创建回测: 表单提交时显示Loading状态
- 回测执行中: 显示进度条或百分比
- 数据加载: Skeleton占位符
- 操作反馈: Toast提示 (成功/失败/警告)
### 5.4 数据可视化
- 资金变化图表使用 ECharts 或 Recharts
- 支持图表缩放和数据点Tooltip
- 图表颜色: 盈利绿色,亏损红色
## 六、非功能需求
### 6.1 性能要求
- 回测列表页面加载时间 < 2秒
- 单个回测任务执行时间 < 5分钟 (30天数据)
- 详情页图表渲染时间 < 1秒
### 6.2 数据准确性
- 回测结果误差 < 0.1%
- 余额计算使用 BigDecimal 避免精度丢失
- 价格和数量精确到小数点后8位
### 6.3 安全性
- 回测数据仅用户本人可见
- API接口需要身份认证
- 防止SQL注入和XSS攻击
## 七、后续迭代规划
### Phase 2 (可选)
- 支持批量创建回测任务
- 回测结果对比功能
- 导出回测报告 (PDF/Excel)
- AI策略推荐
### Phase 3 (可选)
- 实时回测 (边交易边回测)
- 社区策略分享
- 策略市场
---
## 附录: 页面路由规划
- 回测列表: `/copy-trading/backtest`
- 新增回测: `/copy-trading/backtest/create`
- 回测详情: `/copy-trading/backtest/:id`
@@ -0,0 +1,669 @@
# 回测功能设计审查清单
## 一、设计审查要点
### 1.1 产品需求完整性 ✅
**已覆盖的核心功能**:
- ✅ 回测任务的创建、查询、删除
- ✅ 按Leader筛选和排序功能
- ✅ 回测配置参数复用现有跟单配置
- ✅ 回测详情展示 (交易记录、资金曲线图、统计数据)
- ✅ 资金不足时自动停止机制
- ✅ 回测天数限制 (1-30天)
**潜在遗漏点**:
> [!WARNING]
> **需要确认的问题**:
> 1. **回测结果的可见性**: 是否需要支持多用户? 当前设计未涉及权限控制
> 2. **回测任务的生命周期管理**: 是否需要自动清理过期的回测记录?
> 3. **回测进度的实时展示**: 前端如何获取运行中任务的进度? (考虑WebSocket或轮询)
### 1.2 技术设计合理性 ✅
**优点**:
- ✅ 数据库设计规范,索引合理
- ✅ API设计符合RESTful规范
- ✅ 复用现有的 `CopyTradingFilterService`,减少代码冗余
- ✅ 使用 BigDecimal 保证计算精度
- ✅ 异步执行回测任务,不阻塞主线程
**可能的改进点**:
> [!NOTE]
> **建议优化的地方**:
> 1. **历史数据获取**: 当前设计依赖Polymarket API,需要考虑API限流和数据缺失的情况
> 2. **缓存策略**: 建议对Leader历史交易数据使用分层缓存 (内存 + Redis)
> 3. **回测结果的序列化**: 考虑将详细交易记录存储为JSON,减少表的大小
### 1.3 业务逻辑准确性 ✅
**已完善的关键逻辑**:
#### 1.3.1 历史数据获取 ⭐ (已修正)
> [!NOTE]
> **问题**: 现有 `ProcessedTrade` 表字段有限,无法满足回测需求。
>
> **解决方案**: 创建独立的 `backtest_historical_trades`
> - ✅ 存储完整的交易信息(marketId, price, quantity, outcomeIndex 等)
> - ✅ 支持实时数据同步(跟单时同时写入)
> - ✅ 支持通过 API 补充历史数据
> - ✅ 不影响现有跟单功能
#### 1.3.2 卖出匹配逻辑 ⭐ (已修正)
> [!NOTE]
> **改进**: 使用 `outcomeIndex` 支持多元市场
>
> **实现方案**:
> - 持仓键: `marketId + outcomeIndex`(支持多元市场)
> - 比例模式: 按 Leader 卖出比例计算
> - 固定金额模式: 全部卖出
> - 参考 `CopyOrderTracking` 的逻辑
**伪代码**:
```kotlin
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val position = positions[positionKey] ?: continue
val sellQuantity = if (task.copyMode == "RATIO") {
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
position.quantity * (leaderTrade.quantity / position.leaderBuyQuantity)
} else {
position.quantity // 全部卖出
}
} else {
position.quantity // 固定金额模式全部卖出
}
```
#### 1.3.3 价格滑点模拟 ✅ (已决策)
> [!NOTE]
> **用户决策**: 暂不模拟价格滑点
>
> **理由**:
> - 简化回测逻辑
> - 减少复杂度
> - 后续可以作为可选项添加
>
> **实现**: 使用 Leader 的成交价,不进行滑点调整
#### 1.3.2 价格滑点模拟
> [!NOTE]
> **关键问题**: 是否需要模拟价格滑点?
>
> **当前设计**: 不模拟价格滑点,直接使用Leader的成交价
> - 优点: 简化逻辑,回测速度快
> - 缺点: 可能高估收益(实际跟单可能有滑点)
>
> **可选方案**: 增加可配置的滑点参数
> - 买入时: 价格 × (1 + 滑点%)
> - 卖出时: 价格 × (1 - 滑点%)
> - 增加可选的滑点模拟参数 (例如: ±0.5%)
> - 在PRD中补充此配置项
#### 1.3.3 手续费计算 ✅ (已移除)
> [!NOTE]
> **用户决策**: 回测不计算手续费
>
> **理由**:
> - 简化计算逻辑
> - 避免精度问题
> - 降低复杂度
>
> **实现**:
> - 所有交易的 `fee` 字段均为 `0`
> - 买入成本 = 数量 × 价格
> - 卖出收入 = 数量 × 价格
> - 结算收入 = 数量 × 结算价
#### 1.3.4 市场结算处理 ⭐ (已优化)
> [!NOTE]
> **问题**: 市场结束时,未平仓位如何自动结算?
>
> **优化方案** (采纳用户建议):
> - ✅ **实时检查**: 在回测循环中,每处理一笔Leader交易前,检查所有持仓的市场`endDate`
> - ✅ **到期即结算**: 如果 `marketEndDate <= currentTradeTime`,立即结算该持仓
> - ✅ **资金可用**: 结算后的资金立即计入余额,可以用于后续交易
> - ✅ **兜底处理**: 回测结束时,结算所有剩余未到期持仓
>
> **结算价格判断** (通过市场价格):
> - 价格 >= 0.95: 胜出 (按 1.0 结算)
> - 价格 <= 0.05: 失败 (按 0.0 结算)
> - 其他情况: 按成本价保守估计
>
> **实现要点**:
> ```kotlin
> // 在交易循环中实时检查市场到期
> for (leaderTrade in leaderTrades.sortedBy { it.timestamp }) {
>
> // 1. 检查并结算已到期的市场
> val expiredPositions = positions.filter { (_, position) ->
> val marketInfo = getMarketInfo(position.marketId)
> marketInfo.endDate <= leaderTrade.timestamp
> }
>
> for ((positionKey, position) in expiredPositions) {
> val marketPrice = marketPriceService.getCurrentMarketPrice(
> marketId = position.marketId,
> outcomeIndex = position.outcomeIndex ?: 0
> )
>
> val settlementPrice = when {
> marketPrice >= BigDecimal("0.95") -> BigDecimal.ONE // 胜出
> marketPrice <= BigDecimal("0.05") -> BigDecimal.ZERO // 失败
> else -> position.avgPrice // 未结算,按成本价
> }
>
> val settlementValue = position.quantity * settlementPrice
> currentBalance += settlementValue
> positions.remove(positionKey)
> }
>
> // 2. 处理当前Leader交易
> // ...
> }
> ```
>
> **优势**:
> - 更符合真实场景 (市场结束时自动返还资金)
> - 提高资金利用率 (结算资金可参与后续交易)
> - 更准确的收益计算
> - 通过市场价格判断,无需依赖可能不存在的 `winner` 字段
#### 1.3.5 余额不足与持仓处理 ⚠️ (边缘场景) - 已修正
> [!WARNING]
> **关键问题**: 当余额不足但有未卖出持仓时,如何处理?
>
> **场景示例**:
> - 初始余额: $1000
> - 已买入持仓市值: $800(未卖出)
> - 当前余额: $200
> - 新买入订单需要: $300
> - **问题**: 是否允许买入?虽然持仓市值足够,但资金被占用
>
> **推荐方案: 严格模式**
> - ❌ 不允许买入(余额不足)
> - ✅ 仅使用 `currentBalance` 判断
> - ✅ 不计入持仓市值(因为持仓未实现)
> - ✅ 理由: 更真实,避免过于乐观的回测结果
>
> **替代方案: 宽松模式**
> - ✅ 计算"虚拟可用资金" = `currentBalance + 持仓估值`
> - ⚠️ 允许"透支"买入
> - ❌ 风险: 可能产生不切实际的收益
>
> **实际影响**:
> - 严格模式下,资金周转率是限制因素
> - 鼓励快进快出的策略
> - 长期持仓策略会因资金占用而错过后续机会
>
> **已在文档中采用**: 严格模式
#### 1.3.6 每日订单数限制 ✅ (已补充)
> [!NOTE]
> **问题**: 文档提到了 `maxDailyOrders` 参数,但未在算法中实现
>
> **解决方案**: 在回测循环中添加每日订单数统计
>
> **实现**:
> ```kotlin
> // 统计当前交易时间当天已有的订单数
> val dailyOrderCount = trades.count { isSameDay(it.tradeTime, leaderTrade.timestamp) }
>
> if (dailyOrderCount >= task.maxDailyOrders) {
> logger.info("已达到每日最大订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
> continue
> }
> ```
>
> **优势**:
> - 符合实际跟单的风险控制逻辑
> - 避免回测结果过于激进
#### 1.3.7 价格容忍度检查 ✅ (已补充)
> [!NOTE]
> **问题**: 文档提到了 `priceTolerance` 参数,但未在算法中实现
>
> **解决方案**: 在执行交易前检查当前市场价格是否在容忍范围内
>
> **实现**:
> ```kotlin
> if (task.priceTolerance > BigDecimal.ZERO) {
> val tolerance = task.priceTolerance.divide(BigDecimal("100"))
> val minPrice = leaderTrade.price.multiply(BigDecimal.ONE.subtract(tolerance))
> val maxPrice = leaderTrade.price.multiply(BigDecimal.ONE.add(tolerance))
>
> val currentPrice = marketPriceService.getCurrentMarketPrice(
> marketId = leaderTrade.marketId,
> outcomeIndex = leaderTrade.outcomeIndex ?: 0
> )
>
> if (currentPrice < minPrice || currentPrice > maxPrice) {
> logger.info("价格超出容忍度范围: 当前=$currentPrice, 可用范围=[$minPrice, $maxPrice]")
> continue
> }
> }
> ```
#### 1.3.8 回测停止条件 ✅ (已修正)
> [!NOTE]
> **修正**: 基于用户反馈,修正了停止逻辑
>
> **错误设计**:
> ```kotlin
> if (currentBalance < $1) {
> break // ❌ 直接停止,忽略持仓
> }
> ```
>
> **正确设计**:
> ```kotlin
> // 只有"余额不足 且 无持仓"时才停止
> if (currentBalance < $1 && positions.isEmpty()) {
> break // ✅ 确保无持仓时才停止
> }
>
> // 有持仓时继续处理(等待卖出或结算)
> if (currentBalance < $1 && positions.isNotEmpty()) {
> // 继续处理,跳过买入,但执行卖出和结算
> }
> ```
>
> **理由**:
> - 持仓存在意味着可能有后续卖出或市场结算
> - 这些操作会释放资金
> - 过早停止会导致资金无法回收,回测不准确
### 1.4 性能和可扩展性 ✅
**已考虑的优化**:
- ✅ 异步执行,线程池限制并发
- ✅ 分页查询
- ✅ 数据库索引优化
- ✅ 前端虚拟滚动
**需要进一步考虑**:
> [!TIP]
> **性能优化建议**:
> 1. **批量插入交易记录**: 使用 `saveAll()` 而非逐条 `save()`
> 2. **进度更新频率**: 避免每笔交易都更新数据库,改为每100笔或每10秒更新一次
> 3. **历史数据预加载**: 在任务开始前一次性加载所有历史交易,避免多次API调用
## 二、数据库设计补充
### 2.1 新增回测历史交易表
**问题**: 现有 `ProcessedTrade` 表字段有限,无法满足回测需求。
**解决方案**: 创建独立的 `backtest_historical_trades` 表,存储完整的 Leader 历史交易数据。
```sql
CREATE TABLE backtest_historical_trades (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 交易ID(唯一标识)',
market_id VARCHAR(100) NOT NULL COMMENT '市场ID',
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
market_slug VARCHAR(200) DEFAULT NULL COMMENT '市场 slug(用于生成链接)',
side VARCHAR(10) NOT NULL COMMENT '交易方向: BUY/SELL',
outcome VARCHAR(50) DEFAULT NULL COMMENT '市场方向(如 YES, NO 等)',
outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
price DECIMAL(20, 8) NOT NULL COMMENT '交易价格',
size DECIMAL(20, 8) NOT NULL COMMENT '交易数量',
amount DECIMAL(20, 8) NOT NULL COMMENT '交易金额(price × size',
trade_timestamp BIGINT NOT NULL COMMENT '交易时间戳(毫秒)',
-- 元数据
source VARCHAR(20) NOT NULL DEFAULT 'POLLING' COMMENT '数据来源: WEBSOCKET/POLLING/API',
fetched_at BIGINT NOT NULL COMMENT '数据获取时间(毫秒)',
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒)',
UNIQUE INDEX uk_leader_trade (leader_id, trade_id),
INDEX idx_leader_id (leader_id),
INDEX idx_trade_timestamp (trade_timestamp),
INDEX idx_market_id (market_id)
) COMMENT='回测历史交易表';
```
**优势**:
- ✅ 不影响现有跟单系统的 `ProcessedTrade`
- ✅ 存储完整的交易信息,满足回测需求
- ✅ 支持实时数据同步(跟单时同时写入)
- ✅ 支持通过 API 补充历史数据
- ✅ 唯一索引自动去重
### 2.2 移除 max_position_count 字段
**问题**: 文档中包含 `max_position_count` 字段,但 V26 迁移已删除该字段。
**解决方案**: 从 `backtest_task` 表和相关 API 中移除该字段。
### 2.3 其他字段建议
#### `backtest_task`
建议新增以下字段:
```sql
-- 用于计算平均持仓时间
avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)',
-- 用于记录回测使用的数据源
data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED',
-- 用于记录回测执行的详细日志
execution_log TEXT DEFAULT NULL COMMENT '执行日志(JSON格式)'
```
### 2.4 索引优化
建议添加复合索引:
```sql
-- 用于按Leader和收益率查询
CREATE INDEX idx_leader_profit ON backtest_task(leader_id, profit_rate DESC);
-- 用于按状态和创建时间查询
CREATE INDEX idx_status_created ON backtest_task(status, created_at DESC);
```
## 三、API设计补充
### 3.1 API 规范修正
**问题**: 文档中使用 GET/DELETE 方法,违反项目统一使用 POST 的规范。
**修正方案**:
```bash
# ❌ 错误(使用 GET/DELETE
GET /api/backtest/tasks
GET /api/backtest/tasks/{id}
DELETE /api/backtest/tasks/{id}
# ✅ 正确(统一使用 POST
POST /api/backtest/tasks/list
POST /api/backtest/tasks/detail
POST /api/backtest/tasks/delete
```
**完整的 API 列表**:
| 功能 | 方法 | 路径 | 说明 |
|-----|------|------|------|
| 创建回测 | POST | /api/backtest/tasks | 创建新的回测任务 |
| 查询列表 | POST | /api/backtest/tasks/list | 分页查询回测任务列表 |
| 查询详情 | POST | /api/backtest/tasks/detail | 查询单个回测任务详情 |
| 查询交易 | POST | /api/backtest/tasks/trades | 查询回测的交易记录 |
| 删除任务 | POST | /api/backtest/tasks/delete | 删除回测任务 |
| 停止任务 | POST | /api/backtest/tasks/stop | 停止运行中的回测 |
| 查询进度 | POST | /api/backtest/tasks/progress | 查询回测执行进度 |
### 3.2 缺失的API
建议新增以下API:
#### 3.2.1 查询回测进度 (实时更新)
```
POST /api/backtest/tasks/progress
```
**Request Body**:
```json
{
"id": 12345
}
```
**Response**:
```json
{
"success": true,
"data": {
"progress": 65,
"currentBalance": "1150.00",
"totalTrades": 30,
"status": "RUNNING"
}
}
```
#### 3.2.2 批量删除回测任务
```
POST /api/backtest/tasks/batch-delete
```
**Request Body**:
```json
{
"taskIds": [12345, 12346, 12347]
}
```
#### 3.2.3 导出回测报告
```
POST /api/backtest/tasks/export
```
**Request Body**:
```json
{
"id": 12345,
"format": "csv" // 或 "pdf"
}
```
### 3.2 API错误码规范
建议统一错误码:
| 错误码 | 说明 |
|-------|------|
| 40001 | 回测任务不存在 |
| 40002 | Leader不存在 |
| 40003 | 回测天数超出限制 |
| 40004 | 初始金额无效 |
| 40005 | 回测任务正在运行,无法删除 |
| 50001 | 历史数据获取失败 |
| 50002 | 回测执行失败 |
## 四、前端实现补充
### 4.1 状态轮询
对于运行中的回测任务,前端需要定时轮询进度:
```typescript
useEffect(() => {
if (task.status === 'RUNNING') {
const interval = setInterval(async () => {
const progress = await backtestService.getProgress(task.id);
setTask({ ...task, ...progress });
}, 3000); // 每3秒轮询一次
return () => clearInterval(interval);
}
}, [task.status]);
```
### 4.2 图表数据压缩
当交易记录过多时,图表数据需要压缩:
```typescript
// 将数据按时间聚合为最多200个点
const compressChartData = (trades: BacktestTrade[], maxPoints: number = 200) => {
if (trades.length <= maxPoints) return trades;
const interval = Math.floor(trades.length / maxPoints);
return trades.filter((_, index) => index % interval === 0);
};
```
### 4.3 国际化文案
需要在 `locales/` 目录下补充以下文案:
**zh-CN.json**:
```json
{
"backtest": {
"title": "回测管理",
"createTask": "新增回测",
"taskName": "回测名称",
"leader": "Leader",
"initialBalance": "初始金额",
"backtestDays": "回测天数",
"profitAmount": "收益额",
"profitRate": "收益率",
"status": {
"pending": "待执行",
"running": "运行中",
"completed": "已完成",
"stopped": "已停止",
"failed": "失败"
}
}
}
```
## 五、测试计划补充
### 5.1 单元测试
**需要测试的核心方法**:
- `BacktestExecutionService.executeBacktest()` - 回测算法准确性
- `BacktestExecutionService.calculateStatistics()` - 统计数据计算
- `BacktestDataService.getLeaderHistoricalTrades()` - 历史数据获取
**测试用例示例**:
```kotlin
@Test
fun `test backtest with simple buy-sell scenario`() {
// Given: 初始余额1000, Leader买入100@0.5, 卖出100@0.6
val task = createTestTask(initialBalance = 1000.toBigDecimal())
val trades = listOf(
createBuyTrade(quantity = 100.toBigDecimal(), price = 0.5.toBigDecimal()),
createSellTrade(quantity = 100.toBigDecimal(), price = 0.6.toBigDecimal())
)
// When: 执行回测
val result = executionService.executeBacktest(task)
// Then: 验证收益
// 买入: 100 * 0.5 = 50, 手续费0.1, 总成本50.1
// 卖出: 100 * 0.6 = 60, 手续费0.12, 净收入59.88
// 盈利: 59.88 - 50.1 = 9.78
// 最终余额: 1000 - 50.1 + 59.88 = 1009.78
assertEquals(1009.78.toBigDecimal(), result.finalBalance)
assertEquals(9.78.toBigDecimal(), result.profitAmount)
}
```
### 5.2 集成测试
**测试场景**:
1. 端到端测试: 创建任务 → 执行回测 → 查询结果
2. 异常场景: 历史数据为空、API调用失败
3. 边界条件: 余额刚好为0、单笔交易耗尽余额
### 5.3 性能测试
**测试指标**:
- 30天历史数据 (假设1000笔交易) 的回测执行时间 < 5分钟
- 并发5个回测任务时的系统资源占用
- 查询包含10000笔交易的回测详情页面加载时间 < 2秒
## 六、风险评估和缓解方案
### 6.1 数据准确性风险
**风险**: 历史数据不完整或API返回数据有误
**缓解方案**:
1. 数据验证: 检查返回数据的完整性 (是否有时间断层)
2. 数据对比: 使用多个数据源交叉验证
3. 错误标记: 回测结果标注数据质量等级
### 6.2 计算精度风险
**风险**: BigDecimal计算中的舍入误差累积
**缓解方案**:
1. 统一舍入模式: 使用 `RoundingMode.HALF_UP`
2. 精度测试: 编写专门的精度测试用例
3. 误差补偿: 最终余额与理论值的误差 < 0.01 USDC
### 6.3 性能风险
**风险**: 大量回测任务导致系统负载过高
**缓解方案**:
1. 任务队列: 使用异步任务队列 (可选: Redis Queue 或 RabbitMQ)
2. 资源限流: 限制单用户最多创建10个待执行任务
3. 自动清理: 定期清理30天前的回测记录
## 七、需要与用户确认的问题
> [!IMPORTANT]
> **关键决策点 - 需要用户反馈**:
### 7.1 卖出匹配策略
**问题**: 当用户多次买入同一市场时,卖出应该匹配哪笔买入?
**选项**:
- **选项A**: FIFO (先进先出) - 先卖出最早的买入
- **选项B**: 加权平均 - 按平均成本价计算盈亏
- **选项C**: 完全跟随Leader - Leader卖多少比例,我们也卖多少比例
**建议**: 选项C (完全跟随),与实际跟单逻辑保持一致
### 7.2 价格滑点模拟
**问题**: 是否需要在回测中模拟价格滑点?
**选项**:
- **选项A**: 不模拟,使用Leader成交价 (乐观估计)
- **选项B**: 固定滑点 (如买入+0.5%, 卖出-0.5%)
- **选项C**: 可配置滑点,用户自定义
**建议**: 选项C,增加灵活性
### 7.3 数据源选择
**问题**: 历史数据来源?
**选项**:
- **选项A**: 仅使用 Polymarket API
- **选项B**: 优先使用系统记录的 `ProcessedTrade` 表,不足时调用API
- **选项C**: 仅使用 `ProcessedTrade` 表 (限制回测范围为系统运行期间)
**建议**: 选项B,兼顾数据完整性和性能
### 7.4 回测结果保留时长
**问题**: 回测记录保留多久?
**选项**:
- **选项A**: 永久保留
- **选项B**: 保留30天,自动清理
- **选项C**: 用户手动删除,无自动清理
**建议**: 选项B,避免数据库膨胀
## 八、文档总结
### 已完成的文档
1. ✅ **BACKTEST_PRD.md** - 产品需求文档
2. ✅ **BACKTEST_TECHNICAL_DESIGN.md** - 技术设计文档
3. ✅ **BACKTEST_REVIEW_CHECKLIST.md** - 设计审查清单 (本文档)
### 建议补充的文档 (可选)
1. **BACKTEST_API_SPEC.md** - API接口规范 (从技术设计文档提取)
2. **BACKTEST_DATABASE_MIGRATION.md** - 数据库迁移脚本
3. **BACKTEST_TEST_PLAN.md** - 详细测试计划
### 下一步行动
1. **用户Review**: 请用户审查以上文档,确认关键设计点
2. **补充遗漏**: 根据用户反馈补充缺失部分
3. **进入执行**: 用户确认后开始实施开发
---
**审查日期**: 2026-01-30
**审查人**: AI Assistant
**状态**: 待用户确认
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
# Polymarket Data API 游标分页验证
回测拉取 Leader 历史交易改用 **start 游标分页**(不再使用 offset),避免 `offset` 过大(如 3100+)时 API 报错。
## 规则
- `limit` 固定为 500(快速验证可用 50)。
- 首次请求:`start` = 回测开始时间(秒),`end` = 回测结束时间(秒)。
- 若本批返回 **500 条**:取本批中**最大 timestamp**,下一页 `start = max_timestamp`(**不加 1**:同一秒可能有多笔订单,会漏单)。
- 若本批 **不足 500 条**:视为最后一页,不再请求。
- 下一页会与上一页在「最大 timestamp」这一秒重叠,必须按 **tradeIdtransactionHash)去重**
## 快速验证(limit=50
```bash
# 环境变量(替换为实际值)
USER="0x1979ae6b7e6534de9c4539d0c205e582ca637c9d"
START=1769961432
END=1770566207
LIMIT=50
# 第 1 页(游标分页不传 offset)
curl -s "https://data-api.polymarket.com/activity?user=${USER}&limit=${LIMIT}&type=TRADE&start=${START}&end=${END}&sortBy=TIMESTAMP&sortDirection=ASC" | jq 'length'
# 若输出 50,则取本批最大 timestamp 作为下一页 start(不加 1,同一秒可能多笔)
curl -s "https://data-api.polymarket.com/activity?user=${USER}&limit=${LIMIT}&type=TRADE&start=${START}&end=${END}&sortBy=TIMESTAMP&sortDirection=ASC" | jq 'max_by(.timestamp) | .timestamp'
# 假设得到 1770000000,则下一页 start=1770000000(与上一批重叠,需按 tradeId 去重)
# 第 2 页(游标分页,不使用 offset)
NEXT_START=1770000000
curl -s "https://data-api.polymarket.com/activity?user=${USER}&limit=${LIMIT}&type=TRADE&start=${NEXT_START}&end=${END}&sortBy=TIMESTAMP&sortDirection=ASC" | jq 'length'
# 若输出 < 50,则为最后一页
```
## 单条命令示例(第 1 页,limit=50)
```bash
curl -s "https://data-api.polymarket.com/activity?user=0x1979ae6b7e6534de9c4539d0c205e582ca637c9d&limit=50&type=TRADE&start=1769961432&end=1770566207&sortBy=TIMESTAMP&sortDirection=ASC"
```
注意:**不要传 offset**,下一页 `start = 上一批最大 timestamp`(不加 1),同一秒多笔订单不丢,重叠记录按 tradeId 去重。
## 代码位置
- 拉取批次:`BacktestDataService.getLeaderHistoricalTradesBatch()`,返回 `LeaderTradesBatchResult(trades, nextCursorSeconds)`
- 执行循环:`BacktestExecutionService.executeBacktest()`,按 `cursorSeconds` 循环,并用 `seenTradeIds` 去重。
+76
View File
@@ -0,0 +1,76 @@
# 跟单回测功能文档
## 📚 文档清单
本目录包含跟单回测功能的完整设计文档:
### 核心文档
1. **[BACKTEST_PRD.md](./BACKTEST_PRD.md)** - 产品需求文档
- 功能概述与用户故事
- UI/UX设计详细说明
- 业务规则与数据要求
2. **[BACKTEST_TECHNICAL_DESIGN.md](./BACKTEST_TECHNICAL_DESIGN.md)** - 技术设计文档
- 数据库表结构设计
- RESTful API接口规范
- 后端服务架构
- 前端组件设计
- 回测算法实现
3. **[BACKTEST_REVIEW_CHECKLIST.md](./BACKTEST_REVIEW_CHECKLIST.md)** - 设计审查清单
- 设计完整性检查
- 边缘场景处理
- 风险评估与缓解
## 🎯 核心特性
- ✅ 完全复用现有跟单配置参数
- ✅ 实时市场结算(按 `endDate` 检查)
- ✅ 严格余额检查(避免过于乐观的回测)
- ✅ 支持按Leader筛选、按收益排序
- ✅ 详细的交易记录和资金曲线图
## 📖 阅读建议
**产品经理**: 先阅读 PRD,再查看审查清单中的关键决策点
**技术负责人**: 先阅读技术设计文档,再查看审查清单评估风险
**开发工程师**: 按顺序阅读所有文档,重点关注技术设计的实现细节
## 🔄 文档版本
- **创建日期**: 2026-01-30
- **最后更新**: 2026-01-30
- **当前版本**: v1.0
## 📝 关键设计决策
### 1. 市场结算机制
- 采用**实时检查**方式:每笔交易前检查市场 `endDate`
- 到期即结算,资金立即释放可用于后续交易
### 2. 余额检查策略
- 采用**严格模式**:仅使用 `currentBalance`,不计入持仓市值
- 停止条件:余额 < $1 **且** 无任何持仓
### 3. 数据源选择
- 优先使用系统记录的 `ProcessedTrade`
- 不足时调用 Polymarket API 补充历史数据
### 4. 代码复用策略
- 后端:完全复用 `CopyTradingFilterService` 的所有过滤逻辑
- 前端:复用跟单配置表单组件
- 数据库:配置字段与 `CopyTrading` 表保持一致
## 🚀 下一步
完成文档审查后,可以:
1. 创建 `implementation_plan.md` 详细规划实施步骤
2. 开始开发(数据库表 → API → 前端页面)
3. 单元测试和集成测试
---
**文档位置**: `/Users/wrbug/polyhermes/docs/zh/backtest/`
+33 -13
View File
@@ -11,6 +11,7 @@
"antd": "^5.12.0",
"antd-mobile": "^5.34.0",
"axios": "^1.6.2",
"echarts": "^6.0.0",
"ethers": "^6.16.0",
"i18next": "^25.7.1",
"react": "^18.2.0",
@@ -158,7 +159,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1677,7 +1677,6 @@
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1743,7 +1742,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -1940,7 +1938,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2259,7 +2256,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -2471,8 +2467,7 @@
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"peer": true
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
},
"node_modules/debug": {
"version": "4.4.3",
@@ -2581,6 +2576,22 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/electron-to-chromium": {
"version": "1.5.258",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.258.tgz",
@@ -2693,7 +2704,6 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -3372,7 +3382,6 @@
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -5441,7 +5450,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -5453,7 +5461,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6021,7 +6028,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6194,7 +6200,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -6325,6 +6330,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+1
View File
@@ -12,6 +12,7 @@
"antd": "^5.12.0",
"antd-mobile": "^5.34.0",
"axios": "^1.6.2",
"echarts": "^6.0.0",
"ethers": "^6.16.0",
"i18next": "^25.7.1",
"react": "^18.2.0",
+4
View File
@@ -32,6 +32,8 @@ import SystemSettings from './pages/SystemSettings'
import ApiHealthStatus from './pages/ApiHealthStatus'
import RpcNodeSettings from './pages/RpcNodeSettings'
import Announcements from './pages/Announcements'
import BacktestList from './pages/BacktestList'
import BacktestDetail from './pages/BacktestDetail'
import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types'
import { apiService } from './services/api'
@@ -254,6 +256,8 @@ function App() {
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<ProtectedRoute><CopyTradingSellOrders /></ProtectedRoute>} />
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<ProtectedRoute><CopyTradingMatchedOrders /></ProtectedRoute>} />
<Route path="/copy-trading/filtered-orders/:id" element={<ProtectedRoute><FilteredOrdersList /></ProtectedRoute>} />
<Route path="/backtest" element={<ProtectedRoute><BacktestList /></ProtectedRoute>} />
<Route path="/backtest/detail/:id" element={<ProtectedRoute><BacktestDetail /></ProtectedRoute>} />
<Route path="/config" element={<ProtectedRoute><ConfigPage /></ProtectedRoute>} />
<Route path="/positions" element={<ProtectedRoute><PositionList /></ProtectedRoute>} />
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
+258 -72
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Form, Input, Button, Radio, Space, Alert, Tooltip } from 'antd'
import { QuestionCircleOutlined } from '@ant-design/icons'
import { useState, useEffect } from 'react'
import { Form, Input, Button, Radio, Space, Card, Spin, message, Alert, Steps, Tag } from 'antd'
import { KeyOutlined, WalletOutlined, UserOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useAccountStore } from '../store/accountStore'
import {
@@ -9,18 +9,19 @@ import {
getPrivateKeyFromMnemonic,
isValidWalletAddress,
isValidPrivateKey,
isValidMnemonic
isValidMnemonic,
formatUSDC
} from '../utils'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import type { ProxyOption } from '../types'
type ImportType = 'privateKey' | 'mnemonic'
type WalletType = 'magic' | 'safe'
interface AccountImportFormProps {
form: any
onSuccess?: (accountId: number) => void
onCancel?: () => void
showAlert?: boolean
showCancelButton?: boolean
}
@@ -28,23 +29,33 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
form,
onSuccess,
onCancel,
showAlert = true,
showCancelButton = true
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { importAccount, loading } = useAccountStore()
const [importType, setImportType] = useState<ImportType>('privateKey')
const [walletType, setWalletType] = useState<WalletType>('safe')
const [derivedAddress, setDerivedAddress] = useState<string>('')
const [addressError, setAddressError] = useState<string>('')
const [proxyOptions, setProxyOptions] = useState<ProxyOption[]>([])
const [selectedProxyType, setSelectedProxyType] = useState<string>('')
const [loadingProxyOptions, setLoadingProxyOptions] = useState<boolean>(false)
const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址
// 当私钥输入时,自动推导地址
// 当私钥输入时,自动推导地址(不支持换行,自动去除换行符)
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const privateKey = e.target.value.trim()
const raw = e.target.value
const normalized = raw.replace(/\r?\n/g, '')
if (normalized !== raw) {
form.setFieldsValue({ privateKey: normalized })
}
const privateKey = normalized.trim()
if (!privateKey) {
setDerivedAddress('')
setAddressError('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -52,6 +63,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (!isValidPrivateKey(privateKey)) {
setAddressError(t('accountImport.privateKeyInvalid'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -62,18 +76,34 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 自动填充钱包地址字段
form.setFieldsValue({ walletAddress: address })
// 延迟获取代理选项(避免频繁请求)
setTimeout(() => {
fetchProxyOptions(address, privateKey, null)
}, 500)
} catch (error: any) {
setAddressError(error.message || t('accountImport.addressError'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
}
}
// 当助记词输入时,自动推导地址
// 当助记词输入时,自动推导地址(不支持换行,换行符转为空格)
const handleMnemonicChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const mnemonic = e.target.value.trim()
const raw = e.target.value
const normalized = raw.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trimStart()
if (/\r?\n/.test(raw)) {
form.setFieldsValue({ mnemonic: normalized })
}
const mnemonic = normalized.trim()
if (!mnemonic) {
setDerivedAddress('')
setAddressError('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -81,6 +111,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (!isValidMnemonic(mnemonic)) {
setAddressError(t('accountImport.mnemonicInvalid'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -91,14 +124,85 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 自动填充钱包地址字段
form.setFieldsValue({ walletAddress: address })
// 延迟获取代理选项(避免频繁请求)
setTimeout(() => {
fetchProxyOptions(address, null, mnemonic)
}, 500)
} catch (error: any) {
setAddressError(error.message || t('accountImport.addressErrorMnemonic'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
}
}
// 获取代理地址选项
const fetchProxyOptions = async (walletAddress: string, privateKey: string | null, mnemonic: string | null) => {
if (!walletAddress || (!privateKey && !mnemonic)) {
return
}
setLoadingProxyOptions(true)
try {
const response = await apiService.accounts.checkProxyOptions({
walletAddress,
privateKey: privateKey || undefined,
mnemonic: mnemonic || undefined
})
if (response.data.code === 0 && response.data.data) {
const options = response.data.data.options || []
setProxyOptions(options)
// 如果有选项,进入选择步骤
if (options.length > 0) {
setStep('select')
// 如果有资产,默认选择第一个有资产的选项
const hasAssetsOption = options.find((opt: ProxyOption) => opt.hasAssets)
if (hasAssetsOption) {
setSelectedProxyType(hasAssetsOption.walletType)
} else {
// 否则选择第一个选项
setSelectedProxyType(options[0].walletType)
}
} else {
setStep('input')
message.warning(t('accountImport.proxyOption.error') || '未获取到代理地址选项')
}
} else {
setProxyOptions([])
setStep('input')
message.error(response.data.msg || '获取代理地址选项失败')
}
} catch (error: any) {
setProxyOptions([])
setStep('input')
message.error(error.message || '获取代理地址选项失败')
} finally {
setLoadingProxyOptions(false)
}
}
// 切换导入方式时重置状态
useEffect(() => {
setDerivedAddress('')
setAddressError('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
form.setFieldsValue({ walletAddress: '', privateKey: '', mnemonic: '' })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importType])
const handleSubmit = async (values: any) => {
try {
// 如果还在输入步骤,需要先选择代理地址
if (step === 'input' || !selectedProxyType) {
return Promise.reject(new Error(t('accountImport.proxyOptionRequired')))
}
let privateKey: string
let walletAddress: string
@@ -124,14 +228,11 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 如果用户手动输入了地址,验证是否与推导的地址一致
if (values.walletAddress) {
if (values.walletAddress !== derivedAddressFromMnemonic) {
// 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址)
walletAddress = derivedAddressFromMnemonic
} else {
// 地址匹配,使用用户输入的地址
walletAddress = values.walletAddress
}
} else {
// 如果用户没有输入地址,使用推导的地址
walletAddress = derivedAddressFromMnemonic
}
}
@@ -145,14 +246,13 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
privateKey: privateKey,
walletAddress: walletAddress,
accountName: values.accountName,
walletType: walletType
walletType: selectedProxyType
})
// 等待store更新
await new Promise(resolve => setTimeout(resolve, 100))
// 获取新添加的账户ID(通过API获取,因为store可能还没更新)
const { apiService } = await import('../services/api')
const accountsResponse = await apiService.accounts.list()
if (accountsResponse.data.code === 0 && accountsResponse.data.data) {
const newAccounts = accountsResponse.data.data.list || []
@@ -160,76 +260,53 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (newAccount && onSuccess) {
onSuccess(newAccount.id)
} else if (onSuccess) {
// 如果找不到账户,仍然调用onSuccess(可能在其他地方处理)
onSuccess(0)
}
} else if (onSuccess) {
// API调用失败,仍然调用onSuccess
onSuccess(0)
}
return Promise.resolve()
} catch (error: any) {
} catch (error: unknown) {
const err = error as Error & { code?: number }
const isDuplicate = err?.code === 4601
message.error(isDuplicate ? t('accountImport.duplicateAccount') : (err?.message ?? t('accountImport.importFailed')))
return Promise.reject(error)
}
}
const currentStep = step === 'input' ? 0 : 1
return (
<>
{showAlert && (
<Alert
message={t('accountImport.securityTip')}
description={t('accountImport.securityTipDesc')}
type="warning"
showIcon
style={{ marginBottom: '24px' }}
/>
)}
<div style={{ padding: isMobile ? '0 4px' : '0 8px' }}>
<Steps
current={currentStep}
size="small"
style={{ marginBottom: 24 }}
items={[
{ title: t('accountImport.importMethod'), icon: <KeyOutlined /> },
{ title: t('accountImport.selectProxyOption'), icon: <WalletOutlined /> },
{ title: t('accountImport.accountName'), icon: <UserOutlined /> }
]}
/>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
size={isMobile ? 'middle' : 'large'}
>
<Form.Item label={t('accountImport.importMethod')}>
<Form.Item label={t('accountImport.importMethod')} style={{ marginBottom: 16 }}>
<Radio.Group
value={importType}
onChange={(e) => {
setImportType(e.target.value)
setDerivedAddress('')
setAddressError('')
form.setFieldsValue({ walletAddress: '' })
}}
optionType="button"
buttonStyle="solid"
size={isMobile ? 'middle' : 'large'}
>
<Radio value="privateKey">{t('accountImport.privateKey')}</Radio>
<Radio value="mnemonic">{t('accountImport.mnemonic')}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
label={
<span>
{t('accountImport.walletType')}{' '}
<Tooltip
title={t('accountImport.walletTypeHelp')}
overlayInnerStyle={{ whiteSpace: 'pre-line', maxWidth: '300px' }}
>
<QuestionCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
>
<Radio.Group
value={walletType}
onChange={(e) => setWalletType(e.target.value)}
>
<Radio value="safe">
{t('accountImport.walletTypeSafe')}
</Radio>
<Radio value="magic" disabled>
{t('accountImport.walletTypeMagic')} {t('accountImport.magicNotSupported')}
</Radio>
<Radio.Button value="privateKey">{t('accountImport.privateKey')}</Radio.Button>
<Radio.Button value="mnemonic">{t('accountImport.mnemonic')}</Radio.Button>
</Radio.Group>
</Form.Item>
@@ -250,13 +327,15 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
}
}
]}
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
help={addressError || ''}
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
>
<Input.TextArea
rows={3}
rows={2}
placeholder={t('accountImport.privateKeyPlaceholder')}
onChange={handlePrivateKeyChange}
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
disabled={loadingProxyOptions}
/>
</Form.Item>
@@ -282,6 +361,7 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
<Input
placeholder={t('accountImport.walletAddressPlaceholder')}
readOnly={!!derivedAddress}
disabled={loadingProxyOptions}
/>
</Form.Item>
</>
@@ -302,13 +382,15 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
}
}
]}
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
help={addressError || ''}
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
>
<Input.TextArea
rows={4}
rows={2}
placeholder={t('accountImport.mnemonicPlaceholder')}
onChange={handleMnemonicChange}
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
disabled={loadingProxyOptions}
/>
</Form.Item>
@@ -334,39 +416,143 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
<Input
placeholder={t('accountImport.walletAddressPlaceholder')}
readOnly={!!derivedAddress}
disabled={loadingProxyOptions}
/>
</Form.Item>
</>
)}
{/* 请求代理地址时的 loading 提示 */}
{loadingProxyOptions && step === 'input' && (
<Form.Item>
<Alert
message={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Spin size="small" />
<span>{t('accountImport.loadingProxyOptions')}</span>
</div>
}
type="info"
showIcon={false}
style={{ marginBottom: 16 }}
/>
</Form.Item>
)}
{/* 代理地址选项选择 */}
{step === 'select' && (
<Form.Item
label={t('accountImport.selectProxyOption')}
required
rules={[
{
validator: () => {
if (!selectedProxyType) {
return Promise.reject(new Error(t('accountImport.proxyOptionRequired')))
}
return Promise.resolve()
}
}
]}
style={{ marginBottom: 20 }}
>
{loadingProxyOptions ? (
<div style={{ padding: '32px 0', textAlign: 'center' }}>
<Spin tip={t('accountImport.loadingProxyOptions')} />
</div>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
{proxyOptions.map((option) => {
const isSelected = selectedProxyType === option.walletType
const typeLabel = option.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'
return (
<Card
key={option.walletType}
hoverable
onClick={() => setSelectedProxyType(option.walletType)}
size="small"
style={{
cursor: 'pointer',
borderColor: isSelected ? 'var(--ant-color-primary)' : undefined,
borderWidth: isSelected ? 2 : 1,
backgroundColor: isSelected ? 'var(--ant-color-primary-bg)' : undefined,
transition: 'border-color 0.2s, background-color 0.2s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Space size="middle">
<Radio checked={isSelected} />
<Tag color={option.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
{typeLabel}
</Tag>
{option.hasAssets && (
<span style={{ color: '#52c41a', fontSize: 12 }}>
<CheckCircleOutlined /> {t('accountImport.proxyOption.hasAssets')}
</span>
)}
{option.error && (
<span style={{ color: 'var(--ant-color-error)', fontSize: 12 }}>
<ExclamationCircleOutlined /> {t('accountImport.proxyOption.error')}
</span>
)}
</Space>
{!option.error && (
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ant-color-primary)' }}>
{formatUSDC(option.totalBalance)} USDC
</span>
)}
</div>
<div style={{ marginTop: 8, marginLeft: 28, fontSize: 12, color: 'var(--ant-color-text-secondary)', wordBreak: 'break-all' }}>
{option.proxyAddress ? (
<span style={{ fontFamily: 'monospace' }}>{option.proxyAddress}</span>
) : (
'-'
)}
{option.error && (
<span style={{ color: 'var(--ant-color-error)', marginLeft: 8 }}>{option.error}</span>
)}
</div>
<div style={{ marginTop: 8, marginLeft: 28, fontSize: 12, color: 'var(--ant-color-text-secondary)', lineHeight: 1.5 }}>
{t('accountImport.proxyOption.proxyAddressHelp')}
</div>
</Card>
)
})}
</Space>
)}
</Form.Item>
)}
<Form.Item
label={t('accountImport.accountName')}
name="accountName"
style={{ marginBottom: 24 }}
>
<Input placeholder={t('accountImport.accountNamePlaceholder')} />
</Form.Item>
<Form.Item>
<Space>
<Form.Item style={{ marginBottom: 0 }}>
<Space size="middle">
<Button
type="primary"
htmlType="submit"
loading={loading}
disabled={step !== 'select' || !selectedProxyType || loadingProxyOptions}
size={isMobile ? 'middle' : 'large'}
style={isMobile ? { minHeight: 44 } : undefined}
>
{t('accountImport.importAccount')}
</Button>
{showCancelButton && onCancel && (
<Button onClick={onCancel}>
<Button onClick={onCancel} size={isMobile ? 'middle' : 'large'}>
{t('common.cancel')}
</Button>
)}
</Space>
</Form.Item>
</Form>
</>
</div>
)
}
export default AccountImportForm
+9 -3
View File
@@ -20,7 +20,8 @@ import {
CheckCircleOutlined,
SendOutlined,
ApiOutlined,
NotificationOutlined
NotificationOutlined,
LineChartOutlined
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
@@ -71,7 +72,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
const getInitialOpenKeys = (): string[] => {
const path = location.pathname
const keys: string[] = []
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
keys.push('/copy-trading-management')
}
if (path.startsWith('/system-settings')) {
@@ -86,7 +87,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
useEffect(() => {
const path = location.pathname
const keys: string[] = []
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
keys.push('/copy-trading-management')
}
if (path.startsWith('/system-settings')) {
@@ -148,6 +149,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
key: '/templates',
icon: <FileTextOutlined />,
label: t('menu.templates')
},
{
key: '/backtest',
icon: <LineChartOutlined />,
label: t('menu.backtest') || '回测'
}
]
},
+70
View File
@@ -0,0 +1,70 @@
import React from 'react'
import { Select } from 'antd'
import type { Leader } from '../types'
const { Option } = Select
interface LeaderSelectProps {
value?: number
onChange?: (value: number | undefined) => void
onSelectChange?: (value: number | undefined) => void // 选择变化时的回调
leaders: Leader[]
placeholder?: string
disabled?: boolean
showSearch?: boolean
allowClear?: boolean
notFoundContent?: React.ReactNode
style?: React.CSSProperties
}
const LeaderSelect: React.FC<LeaderSelectProps> = ({
value,
onChange,
onSelectChange,
leaders,
placeholder,
disabled,
showSearch = true,
allowClear = false,
notFoundContent,
style
}) => {
// 处理选择变化
const handleChange = (val: number | undefined) => {
if (onChange) {
onChange(val)
}
if (onSelectChange) {
onSelectChange(val)
}
}
return (
<Select
value={value}
onChange={handleChange}
placeholder={placeholder}
disabled={disabled}
showSearch={showSearch}
allowClear={allowClear}
notFoundContent={notFoundContent}
style={style}
optionFilterProp="label"
optionLabelProp="label"
>
{leaders.map(leader => {
const label = leader.leaderName || `Leader ${leader.id}`
return (
<Option key={leader.id} value={leader.id} label={label}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span>{label}</span>
<span style={{ fontSize: '12px', color: '#999' }}>{leader.leaderAddress}</span>
</div>
</Option>
)
})}
</Select>
)
}
export default LeaderSelect
+180 -7
View File
@@ -16,6 +16,10 @@
"submit": "Submit",
"reset": "Reset",
"close": "Close",
"create": "Create",
"copy": "Copy",
"copySuccess": "Copied successfully",
"copyFailed": "Copy failed",
"yes": "Yes",
"no": "No",
"actions": "Actions",
@@ -34,7 +38,10 @@
"previous": "Previous",
"next": "Next",
"page": "Page",
"pageOf": "Page"
"pageOf": "Page",
"ascending": "Ascending",
"descending": "Descending",
"day": "day"
},
"account": {
"title": "Account Management",
@@ -46,6 +53,7 @@
"accountId": "Account ID",
"accountName": "Account Name",
"walletAddress": "Wallet Address",
"walletType": "Wallet Type",
"balance": "Account Balance",
"refreshBalance": "Refresh Balance",
"apiCredentials": "API Credentials Configuration",
@@ -115,6 +123,7 @@
"importAccount": "Import Account",
"accountName": "Account Name",
"walletAddress": "Wallet Address",
"walletType": "Wallet Type",
"proxyAddress": "Proxy Wallet Address",
"apiCredentials": "API Credentials",
"balance": "Balance",
@@ -171,8 +180,6 @@
"accountImport": {
"title": "Import Account",
"back": "Back",
"securityTip": "Security Tip",
"securityTipDesc": "Private keys will be stored in the backend database. Please ensure database access is secure. HTTPS is recommended.",
"importMethod": "Import Method",
"privateKey": "Private Key",
"mnemonic": "Mnemonic",
@@ -206,9 +213,29 @@
"addressErrorMnemonic": "Cannot derive address from mnemonic",
"walletType": "Wallet Type",
"walletTypeHelp": "Web3 Wallet: Polymarket accounts connected via browser wallets like MetaMask\nMagic: Polymarket accounts logged in via email or social accounts (Google, Twitter, etc.)",
"walletTypeMagic": "Magic (Email/Social Login)",
"walletTypeSafe": "Web3 Wallet",
"magicNotSupported": "(Not Supported)"
"loadingProxyOptions": "Loading proxy addresses and asset information...",
"selectProxyOption": "Please select account type",
"proxyOptionRequired": "Please select a proxy address",
"proxyOption": {
"magic": {
"description": "Email/Social Login Account (Magic)",
"title": "Magic Proxy Address"
},
"safe": {
"description": "MetaMask Browser Wallet Account (Safe)",
"title": "Safe Proxy Address"
},
"proxyAddress": "Proxy Address",
"proxyAddressHelp": "The proxy address is the wallet address you actually use on Polymarket: Magic for email/social login accounts, Safe for browser wallet (e.g. MetaMask) accounts. Please select the one that matches how you log in to Polymarket.",
"availableBalance": "Available Balance",
"positionBalance": "Position Balance",
"totalBalance": "Total Balance",
"positionCount": "Position Count",
"noAssets": "No Assets",
"hasAssets": "Has Assets",
"error": "Failed to fetch",
"select": "Select this proxy address"
}
},
"leader": {
"title": "Leader Management",
@@ -240,6 +267,7 @@
"templates": "Templates",
"copyTradingConfig": "Copy Trading Config",
"positions": "Position Management",
"backtest": "Backtest",
"statistics": "Statistics",
"announcements": "Announcements",
"users": "User Management",
@@ -426,14 +454,20 @@
"openWebsite": "Open Website",
"all": "All",
"copyTradingCount": "Copy Trading Count",
"backtestCount": "Backtest Count",
"copyTradingRelations": "{{count}} copy trading relations",
"viewCopyTradings": "View Copy Tradings",
"viewBacktests": "View Backtests",
"createdAt": "Created At",
"noData": "No Leader data",
"fetchFailed": "Failed to get Leader list",
"deleteSuccess": "Leader deleted successfully",
"deleteFailed": "Failed to delete Leader",
"deleteConfirm": "Are you sure you want to delete this Leader?",
"deleteConfirmDesc": "This Leader has {{count}} copy trading relations, please delete them first"
"deleteConfirmDesc": "This Leader has {{count}} copy trading relations, please delete them first",
"openDetailFailed": "Failed to open detail",
"noCopyTradings": "No copy trading configs",
"noBacktests": "No backtests"
},
"leaderAdd": {
"title": "Add Leader",
@@ -506,6 +540,7 @@
"totalBalance": "Total Balance",
"positions": "Positions",
"noPositions": "No positions",
"market": "Market",
"marketId": "Market ID",
"side": "Side",
"quantity": "Quantity",
@@ -964,8 +999,10 @@
"priceRange": "Price Range Mismatch",
"maxPositionValue": "Exceeds Max Position Value",
"marketEndDate": "Market End Date Exceeds Limit",
"keywordFilter": "Keyword Filter",
"unknown": "Unknown Reason"
},
"orderDetails": "Order Details",
"noData": "No filtered orders",
"noFilteredOrders": "No filtered orders"
},
@@ -1226,5 +1263,141 @@
"providerQuickNode": "QuickNode",
"providerChainstack": "Chainstack",
"providerGetBlock": "GetBlock"
},
"backtest": {
"title": "Backtest",
"taskName": "Task Name",
"leader": "Leader",
"initialBalance": "Initial Balance",
"backtestDays": "Backtest Days",
"status": "Status",
"progress": "Progress",
"startTime": "Start Time",
"endTime": "End Time",
"finalBalance": "Final Balance",
"profitAmount": "Profit Amount",
"profitRate": "Profit Rate",
"totalTrades": "Total Trades",
"buyTrades": "Buy Trades",
"sellTrades": "Sell Trades",
"winTrades": "Win Trades",
"lossTrades": "Loss Trades",
"winRate": "Win Rate",
"maxProfit": "Max Profit",
"maxLoss": "Max Loss",
"maxDrawdown": "Max Drawdown",
"avgHoldingTime": "Avg Holding Time",
"statusPending": "Pending",
"statusRunning": "Running",
"statusCompleted": "Completed",
"statusStopped": "Stopped",
"statusFailed": "Failed",
"createTask": "Create Backtest Task",
"taskList": "Backtest Task List",
"taskDetail": "Backtest Task Detail",
"tradeRecords": "Trade Records",
"config": "Configuration",
"statistics": "Statistics",
"chart": "Balance Chart",
"createSuccess": "Created successfully",
"createFailed": "Failed to create",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Failed to delete",
"stop": "Stop",
"stopSuccess": "Stopped successfully",
"stopFailed": "Failed to stop",
"retry": "Retry",
"retrySuccess": "Retry successfully",
"retryFailed": "Failed to retry",
"retryConfirm": "Are you sure you want to retry this backtest task? It will continue from the breakpoint and preserve processed trades.",
"deleteConfirm": "Are you sure you want to delete this backtest task?",
"stopConfirm": "Are you sure you want to stop this backtest task?",
"createCopyTrading": "Create Copy Trading",
"createCopyTradingSuccess": "Copy trading config created successfully",
"noTasks": "No backtest tasks",
"noTrades": "No trade records",
"fetchTasksFailed": "Failed to fetch task list",
"fetchTaskDetailFailed": "Failed to fetch task detail",
"fetchTradesFailed": "Failed to fetch trade records",
"balanceChart": "Balance Change",
"pnlChart": "PnL Change",
"executionTime": "Execution Time",
"runningDuration": "Running Duration",
"estimatedRemaining": "Estimated Remaining",
"leaderAddress": "Leader Address",
"leaderName": "Leader Name",
"copyMode": "Copy Mode",
"copyModeRatio": "Ratio",
"copyModeFixed": "Fixed Amount",
"copyRatio": "Copy Ratio",
"fixedAmount": "Fixed Amount",
"maxOrderSize": "Max Order Size",
"minOrderSize": "Min Order Size",
"maxDailyLoss": "Max Daily Loss",
"maxDailyOrders": "Max Daily Orders",
"priceTolerance": "Price Tolerance",
"delaySeconds": "Delay Seconds",
"supportSell": "Copy Sell",
"minOrderDepth": "Min Order Depth",
"maxSpread": "Max Spread",
"minPrice": "Min Price",
"maxPrice": "Max Price",
"maxPositionValue": "Max Position Value",
"keywordFilterMode": "Keyword Filter Mode",
"keywordFilterModeDisabled": "Disabled",
"keywordFilterModeWhitelist": "Whitelist",
"keywordFilterModeBlacklist": "Blacklist",
"keywords": "Keywords",
"maxMarketEndDate": "Market End Date Limit",
"tradeTime": "Trade Time",
"marketId": "Market ID",
"marketTitle": "Market Title",
"side": "Side",
"sideBuy": "Buy",
"sideSell": "Sell",
"sideSettlement": "Settlement",
"outcome": "Outcome",
"quantity": "Quantity",
"price": "Price",
"amount": "Amount",
"fee": "Fee",
"profitLoss": "Profit/Loss",
"balanceAfter": "Balance After",
"leaderTradeId": "Leader Trade ID",
"loading": "Loading...",
"refreshing": "Refreshing...",
"starting": "Starting...",
"stopping": "Stopping...",
"errorOccurred": "Error occurred",
"retry": "Retry",
"taskNameRequired": "Please enter task name",
"leaderRequired": "Please select Leader",
"initialBalanceRequired": "Please enter initial balance",
"initialBalanceInvalid": "Initial balance must be greater than 0",
"backtestDaysRequired": "Please enter backtest days",
"backtestDaysInvalid": "Backtest days must be between 1-15",
"copyRatioRequired": "Please enter copy ratio",
"copyRatioInvalid": "Copy ratio must be between 0.01-10000",
"copyRatioPlaceholder": "For example: 100 means 100% (1:1 copy), default 100%",
"copyRatioTooltip": "Copy ratio represents the percentage of copy amount relative to Leader order amount. For example: 100% means 1:1 copy, 50% means half position copy, 200% means double copy",
"fixedAmountRequired": "Please enter fixed amount",
"fixedAmountInvalid": "Fixed amount must be greater than 0",
"priceFilters": "Price Filters",
"priceRange": "Price Range",
"priceRangeTooltip": "Only copy Leader orders with price within the specified range. Leave empty to disable. Example: Enter 0.11 and 0.89 to only copy orders with price between 0.11 and 0.89; enter only max price 0.89 to only copy orders with price below 0.89; enter only min price 0.11 to only copy orders with price above 0.11.",
"minPricePlaceholder": "Min price (leave empty to disable)",
"maxPricePlaceholder": "Max price (leave empty to disable)",
"keywordsPlaceholder": "Please enter keywords, press Enter to add",
"maxPositionValuePlaceholder": "Leave empty to disable max position limit",
"delaySecondsHint": "Delay execution to simulate real copy trading delay",
"supportSellHint": "Whether to follow Leader sell orders",
"sortBy": "Sort By",
"sortOrder": "Sort Order",
"createdAt": "Created At",
"rerun": "Re-run",
"rerunConfirm": "Create a new backtest task with the same config?",
"rerunTaskNamePlaceholder": "New task name (leave empty for \"Original name (copy)\")",
"rerunSuccess": "New backtest task created",
"rerunFailed": "Re-run failed"
}
}
+179 -7
View File
@@ -34,7 +34,14 @@
"pageOf": "第",
"success": "成功",
"failed": "失败",
"close": "关闭"
"close": "关闭",
"create": "创建",
"copy": "复制",
"copySuccess": "复制成功",
"copyFailed": "复制失败",
"ascending": "升序",
"descending": "降序",
"day": "天"
},
"login": {
"title": "登录",
@@ -114,6 +121,7 @@
"importAccount": "导入账户",
"accountName": "账户名称",
"walletAddress": "钱包地址",
"walletType": "钱包类型",
"proxyAddress": "代理钱包地址",
"apiCredentials": "API 凭证",
"balance": "余额",
@@ -171,8 +179,6 @@
"accountImport": {
"title": "导入账户",
"back": "返回",
"securityTip": "安全提示",
"securityTipDesc": "私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。",
"importMethod": "导入方式",
"privateKey": "私钥",
"mnemonic": "助记词",
@@ -206,9 +212,29 @@
"addressErrorMnemonic": "无法从助记词推导地址",
"walletType": "钱包类型",
"walletTypeHelp": "Web3钱包:使用 MetaMask 等浏览器钱包连接的 Polymarket 账户\nMagic:通过邮箱或社交账号(如 Google、Twitter)登录的 Polymarket 账户",
"walletTypeMagic": "Magic(邮箱/社交账号登录)",
"walletTypeSafe": "Web3钱包",
"magicNotSupported": "(暂不支持)"
"loadingProxyOptions": "正在获取代理地址和资产信息...",
"selectProxyOption": "请选择账户类型",
"proxyOptionRequired": "请选择一个代理地址",
"proxyOption": {
"magic": {
"description": "邮箱/社交账号登录账户(Magic",
"title": "Magic 代理地址"
},
"safe": {
"description": "MetaMask 等浏览器钱包账户(Safe",
"title": "Safe 代理地址"
},
"proxyAddress": "代理地址",
"proxyAddressHelp": "代理地址是您在 Polymarket 上实际使用的钱包地址:Magic 为邮箱/社交登录账户,Safe 为浏览器钱包(如 MetaMask)账户。请选择与您 Polymarket 登录方式一致的一项。",
"availableBalance": "可用余额",
"positionBalance": "仓位余额",
"totalBalance": "总余额",
"positionCount": "持仓数量",
"noAssets": "无资产",
"hasAssets": "有资产",
"error": "获取失败",
"select": "选择此代理地址"
}
},
"leader": {
"title": "Leader 管理",
@@ -240,6 +266,7 @@
"templates": "跟单模板",
"copyTradingConfig": "跟单配置",
"positions": "仓位管理",
"backtest": "回测",
"statistics": "统计信息",
"announcements": "公告",
"users": "用户管理",
@@ -426,14 +453,20 @@
"openWebsite": "打开网页",
"all": "全部",
"copyTradingCount": "跟单数",
"backtestCount": "回测数",
"copyTradingRelations": "{{count}} 个跟单关系",
"viewCopyTradings": "查看跟单配置",
"viewBacktests": "查看回测",
"createdAt": "创建时间",
"noData": "暂无 Leader 数据",
"fetchFailed": "获取 Leader 列表失败",
"deleteSuccess": "删除 Leader 成功",
"deleteFailed": "删除 Leader 失败",
"deleteConfirm": "确定要删除这个 Leader 吗?",
"deleteConfirmDesc": "该 Leader 还有 {{count}} 个跟单关系,请先删除跟单关系"
"deleteConfirmDesc": "该 Leader 还有 {{count}} 个跟单关系,请先删除跟单关系",
"openDetailFailed": "打开详情失败",
"noCopyTradings": "暂无跟单配置",
"noBacktests": "暂无回测"
},
"leaderAdd": {
"title": "添加 Leader",
@@ -506,6 +539,7 @@
"totalBalance": "总余额",
"positions": "持仓列表",
"noPositions": "暂无持仓",
"market": "市场",
"marketId": "市场ID",
"side": "方向",
"quantity": "数量",
@@ -964,8 +998,10 @@
"priceRange": "价格区间不符",
"maxPositionValue": "超过最大仓位金额",
"marketEndDate": "市场截止时间超出限制",
"keywordFilter": "关键字过滤",
"unknown": "未知原因"
},
"orderDetails": "订单详情",
"noData": "暂无已过滤订单",
"noFilteredOrders": "暂无已过滤订单"
},
@@ -1226,5 +1262,141 @@
"providerQuickNode": "QuickNode",
"providerChainstack": "Chainstack",
"providerGetBlock": "GetBlock"
},
"backtest": {
"title": "回测",
"taskName": "任务名称",
"leader": "Leader",
"initialBalance": "初始资金",
"backtestDays": "回测天数",
"status": "状态",
"progress": "进度",
"startTime": "开始时间",
"endTime": "结束时间",
"finalBalance": "最终资金",
"profitAmount": "收益金额",
"profitRate": "收益率",
"totalTrades": "总交易数",
"buyTrades": "买入笔数",
"sellTrades": "卖出笔数",
"winTrades": "盈利笔数",
"lossTrades": "亏损笔数",
"winRate": "胜率",
"maxProfit": "最大单笔盈利",
"maxLoss": "最大单笔亏损",
"maxDrawdown": "最大回撤",
"avgHoldingTime": "平均持仓时间",
"statusPending": "等待中",
"statusRunning": "运行中",
"statusCompleted": "已完成",
"statusStopped": "已停止",
"statusFailed": "失败",
"createTask": "创建回测任务",
"taskList": "回测任务列表",
"taskDetail": "回测任务详情",
"tradeRecords": "交易记录",
"config": "配置",
"statistics": "统计",
"chart": "资金曲线",
"createSuccess": "创建成功",
"createFailed": "创建失败",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"stop": "停止",
"stopSuccess": "停止成功",
"stopFailed": "停止失败",
"retry": "重试",
"retrySuccess": "重试成功",
"retryFailed": "重试失败",
"retryConfirm": "确定重新运行此回测任务吗?将从断点继续执行,保留已处理的交易记录。",
"deleteConfirm": "确定删除此回测任务吗?",
"stopConfirm": "确定停止此回测任务吗?",
"createCopyTrading": "创建跟单",
"createCopyTradingSuccess": "跟单配置创建成功",
"noTasks": "暂无回测任务",
"noTrades": "暂无交易记录",
"fetchTasksFailed": "获取任务列表失败",
"fetchTaskDetailFailed": "获取任务详情失败",
"fetchTradesFailed": "获取交易记录失败",
"balanceChart": "资金变化",
"pnlChart": "盈亏变化",
"executionTime": "执行时间",
"runningDuration": "运行时长",
"estimatedRemaining": "预计剩余",
"leaderAddress": "Leader 地址",
"leaderName": "Leader 名称",
"copyMode": "跟单模式",
"copyModeRatio": "比例",
"copyModeFixed": "固定金额",
"copyRatio": "跟单比例",
"fixedAmount": "固定金额",
"maxOrderSize": "最大单笔订单",
"minOrderSize": "最小单笔订单",
"maxDailyLoss": "最大每日亏损",
"maxDailyOrders": "最大每日订单数",
"priceTolerance": "价格容忍度",
"delaySeconds": "延迟秒数",
"supportSell": "跟单卖出",
"minOrderDepth": "最小订单深度",
"maxSpread": "最大价差",
"minPrice": "最低价格",
"maxPrice": "最高价格",
"maxPositionValue": "最大仓位金额",
"keywordFilterMode": "关键字过滤模式",
"keywordFilterModeDisabled": "禁用",
"keywordFilterModeWhitelist": "白名单",
"keywordFilterModeBlacklist": "黑名单",
"keywords": "关键字",
"maxMarketEndDate": "市场截止时间限制",
"tradeTime": "交易时间",
"marketId": "市场ID",
"marketTitle": "市场标题",
"side": "方向",
"sideBuy": "买入",
"sideSell": "卖出",
"sideSettlement": "结算",
"outcome": "结果",
"quantity": "数量",
"price": "价格",
"amount": "金额",
"fee": "手续费",
"profitLoss": "盈亏",
"balanceAfter": "交易后余额",
"leaderTradeId": "Leader 交易ID",
"loading": "加载中...",
"refreshing": "刷新中...",
"starting": "启动中...",
"stopping": "停止中...",
"errorOccurred": "发生错误",
"retry": "重试",
"taskNameRequired": "请输入任务名称",
"leaderRequired": "请选择 Leader",
"initialBalanceRequired": "请输入初始资金",
"initialBalanceInvalid": "初始资金必须大于 0",
"backtestDaysRequired": "请输入回测天数",
"backtestDaysInvalid": "回测天数必须在 1-15 之间",
"copyRatioRequired": "请输入跟单比例",
"copyRatioInvalid": "跟单比例必须在 0.01-10000 之间",
"copyRatioPlaceholder": "例如:100 表示 100%1:1 跟单),默认 100%",
"copyRatioTooltip": "跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单",
"fixedAmountRequired": "请输入固定金额",
"fixedAmountInvalid": "固定金额必须大于 0",
"priceFilters": "价格过滤",
"priceRange": "价格区间",
"priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。",
"minPricePlaceholder": "最低价(留空不限制)",
"maxPricePlaceholder": "最高价(留空不限制)",
"keywordsPlaceholder": "请输入关键字,按回车添加",
"maxPositionValuePlaceholder": "留空表示不启用最大仓位限制",
"delaySecondsHint": "延迟执行模拟真实跟单延迟",
"supportSellHint": "是否跟随 Leader 卖出",
"sortBy": "排序字段",
"sortOrder": "排序顺序",
"createdAt": "创建时间",
"rerun": "重新测试",
"rerunConfirm": "将按当前配置创建新的回测任务,是否继续?",
"rerunTaskNamePlaceholder": "新任务名称(留空使用「原名称 (副本)」)",
"rerunSuccess": "已创建新回测任务",
"rerunFailed": "重新测试失败"
}
}
+180 -7
View File
@@ -16,6 +16,10 @@
"submit": "提交",
"reset": "重置",
"close": "關閉",
"create": "創建",
"copy": "複製",
"copySuccess": "複製成功",
"copyFailed": "複製失敗",
"yes": "是",
"no": "否",
"actions": "操作",
@@ -34,7 +38,10 @@
"previous": "上一頁",
"next": "下一頁",
"page": "頁",
"pageOf": "第"
"pageOf": "第",
"ascending": "升序",
"descending": "降序",
"day": "天"
},
"account": {
"title": "賬戶管理",
@@ -46,6 +53,7 @@
"accountId": "賬戶ID",
"accountName": "賬戶名稱",
"walletAddress": "錢包地址",
"walletType": "錢包類型",
"balance": "賬戶餘額",
"refreshBalance": "刷新餘額",
"apiCredentials": "API 憑證配置",
@@ -115,6 +123,7 @@
"importAccount": "導入賬戶",
"accountName": "賬戶名稱",
"walletAddress": "錢包地址",
"walletType": "錢包類型",
"proxyAddress": "代理錢包地址",
"apiCredentials": "API 憑證",
"balance": "餘額",
@@ -171,8 +180,6 @@
"accountImport": {
"title": "導入賬戶",
"back": "返回",
"securityTip": "安全提示",
"securityTipDesc": "私鑰將存儲在後端數據庫中,請確保數據庫訪問安全。建議使用 HTTPS 連接。",
"importMethod": "導入方式",
"privateKey": "私鑰",
"mnemonic": "助記詞",
@@ -206,9 +213,29 @@
"addressErrorMnemonic": "無法從助記詞推導地址",
"walletType": "錢包類型",
"walletTypeHelp": "Web3錢包:使用 MetaMask 等瀏覽器錢包連接的 Polymarket 帳戶\nMagic:透過郵箱或社群帳號(如 Google、Twitter)登入的 Polymarket 帳戶",
"walletTypeMagic": "Magic(郵箱/社群帳號登入)",
"walletTypeSafe": "Web3錢包",
"magicNotSupported": "(暫不支持)"
"loadingProxyOptions": "正在獲取代理地址和資產信息...",
"selectProxyOption": "請選擇帳戶類型",
"proxyOptionRequired": "請選擇一個代理地址",
"proxyOption": {
"magic": {
"description": "郵箱/社群帳號登入帳戶(Magic",
"title": "Magic 代理地址"
},
"safe": {
"description": "MetaMask 等瀏覽器錢包帳戶(Safe",
"title": "Safe 代理地址"
},
"proxyAddress": "代理地址",
"proxyAddressHelp": "代理地址是您在 Polymarket 上實際使用的錢包地址:Magic 為郵箱/社群登入帳戶,Safe 為瀏覽器錢包(如 MetaMask)帳戶。請選擇與您 Polymarket 登入方式一致的一項。",
"availableBalance": "可用餘額",
"positionBalance": "倉位餘額",
"totalBalance": "總餘額",
"positionCount": "持倉數量",
"noAssets": "無資產",
"hasAssets": "有資產",
"error": "獲取失敗",
"select": "選擇此代理地址"
}
},
"leader": {
"title": "Leader 管理",
@@ -240,6 +267,7 @@
"templates": "跟單模板",
"copyTradingConfig": "跟單配置",
"positions": "倉位管理",
"backtest": "回測",
"statistics": "統計信息",
"announcements": "公告",
"users": "用戶管理",
@@ -426,14 +454,20 @@
"openWebsite": "打開網頁",
"all": "全部",
"copyTradingCount": "跟單數",
"backtestCount": "回測數",
"copyTradingRelations": "{{count}} 個跟單關係",
"viewCopyTradings": "查看跟單配置",
"viewBacktests": "查看回測",
"createdAt": "創建時間",
"noData": "暫無 Leader 數據",
"fetchFailed": "獲取 Leader 列表失敗",
"deleteSuccess": "刪除 Leader 成功",
"deleteFailed": "刪除 Leader 失敗",
"deleteConfirm": "確定要刪除這個 Leader 嗎?",
"deleteConfirmDesc": "該 Leader 還有 {{count}} 個跟單關係,請先刪除跟單關係"
"deleteConfirmDesc": "該 Leader 還有 {{count}} 個跟單關係,請先刪除跟單關係",
"openDetailFailed": "打開詳情失敗",
"noCopyTradings": "暫無跟單配置",
"noBacktests": "暫無回測"
},
"leaderAdd": {
"title": "添加 Leader",
@@ -506,6 +540,7 @@
"totalBalance": "總餘額",
"positions": "持倉列表",
"noPositions": "暫無持倉",
"market": "市場",
"marketId": "市場ID",
"side": "方向",
"quantity": "數量",
@@ -964,8 +999,10 @@
"priceRange": "價格區間不符",
"maxPositionValue": "超過最大倉位金額",
"marketEndDate": "市場截止時間超出限制",
"keywordFilter": "關鍵字過濾",
"unknown": "未知原因"
},
"orderDetails": "訂單詳情",
"noData": "暫無已過濾訂單",
"noFilteredOrders": "暫無已過濾訂單"
},
@@ -1226,5 +1263,141 @@
"providerQuickNode": "QuickNode",
"providerChainstack": "Chainstack",
"providerGetBlock": "GetBlock"
},
"backtest": {
"title": "回測",
"taskName": "任務名稱",
"leader": "Leader",
"initialBalance": "初始資金",
"backtestDays": "回測天數",
"status": "狀態",
"progress": "進度",
"startTime": "開始時間",
"endTime": "結束時間",
"finalBalance": "最終資金",
"profitAmount": "收益金額",
"profitRate": "收益率",
"totalTrades": "總交易數",
"buyTrades": "買入筆數",
"sellTrades": "賣出筆數",
"winTrades": "盈利筆數",
"lossTrades": "虧損筆數",
"winRate": "勝率",
"maxProfit": "最大單筆盈利",
"maxLoss": "最大單筆虧損",
"maxDrawdown": "最大回撤",
"avgHoldingTime": "平均持倉時間",
"statusPending": "等待中",
"statusRunning": "運行中",
"statusCompleted": "已完成",
"statusStopped": "已停止",
"statusFailed": "失敗",
"createTask": "創建回測任務",
"taskList": "回測任務列表",
"taskDetail": "回測任務詳情",
"tradeRecords": "交易記錄",
"config": "配置",
"statistics": "統計",
"chart": "資金曲線",
"createSuccess": "創建成功",
"createFailed": "創建失敗",
"deleteSuccess": "刪除成功",
"deleteFailed": "刪除失敗",
"stop": "停止",
"stopSuccess": "停止成功",
"stopFailed": "停止失敗",
"retry": "重試",
"retrySuccess": "重試成功",
"retryFailed": "重試失敗",
"retryConfirm": "確定重新運行此回測任務嗎?將從斷點繼續執行,保留已處理的交易記錄。",
"deleteConfirm": "確定刪除此回測任務嗎?",
"stopConfirm": "確定停止此回測任務嗎?",
"createCopyTrading": "創建跟單",
"createCopyTradingSuccess": "跟單配置創建成功",
"noTasks": "暫無回測任務",
"noTrades": "暫無交易記錄",
"fetchTasksFailed": "獲取任務列表失敗",
"fetchTaskDetailFailed": "獲取任務詳情失敗",
"fetchTradesFailed": "獲取交易記錄失敗",
"balanceChart": "資金變化",
"pnlChart": "盈虧變化",
"executionTime": "執行時間",
"runningDuration": "運行時長",
"estimatedRemaining": "預計剩餘",
"leaderAddress": "Leader 地址",
"leaderName": "Leader 名稱",
"copyMode": "跟單模式",
"copyModeRatio": "比例",
"copyModeFixed": "固定金額",
"copyRatio": "跟單比例",
"fixedAmount": "固定金額",
"maxOrderSize": "最大單筆訂單",
"minOrderSize": "最小單筆訂單",
"maxDailyLoss": "最大每日虧損",
"maxDailyOrders": "最大每日訂單數",
"priceTolerance": "價格容忍度",
"delaySeconds": "延遲秒數",
"supportSell": "跟單賣出",
"minOrderDepth": "最小訂單深度",
"maxSpread": "最大價差",
"minPrice": "最低價格",
"maxPrice": "最高價格",
"maxPositionValue": "最大倉位金額",
"keywordFilterMode": "關鍵字過濾模式",
"keywordFilterModeDisabled": "禁用",
"keywordFilterModeWhitelist": "白名單",
"keywordFilterModeBlacklist": "黑名單",
"keywords": "關鍵字",
"maxMarketEndDate": "市場截止時間限制",
"tradeTime": "交易時間",
"marketId": "市場ID",
"marketTitle": "市場標題",
"side": "方向",
"sideBuy": "買入",
"sideSell": "賣出",
"sideSettlement": "結算",
"outcome": "結果",
"quantity": "數量",
"price": "價格",
"amount": "金額",
"fee": "手續費",
"profitLoss": "盈虧",
"balanceAfter": "交易後餘額",
"leaderTradeId": "Leader 交易ID",
"loading": "加載中...",
"refreshing": "刷新中...",
"starting": "啟動中...",
"stopping": "停止中...",
"errorOccurred": "發生錯誤",
"retry": "重試",
"taskNameRequired": "請輸入任務名稱",
"leaderRequired": "請選擇 Leader",
"initialBalanceRequired": "請輸入初始資金",
"initialBalanceInvalid": "初始資金必須大於 0",
"backtestDaysRequired": "請輸入回測天數",
"backtestDaysInvalid": "回測天數必須在 1-15 之間",
"copyRatioRequired": "請輸入跟單比例",
"copyRatioInvalid": "跟單比例必須在 0.01-10000 之間",
"copyRatioPlaceholder": "例如:100 表示 100%1:1 跟單),預設 100%",
"copyRatioTooltip": "跟單比例表示跟單金額相對於 Leader 訂單金額的百分比。例如:100% 表示 1:1 跟單,50% 表示半倉跟單,200% 表示雙倍跟單",
"fixedAmountRequired": "請輸入固定金額",
"fixedAmountInvalid": "固定金額必須大於 0",
"priceFilters": "價格過濾",
"priceRange": "價格區間",
"priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。",
"minPricePlaceholder": "最低價(留空不限制)",
"maxPricePlaceholder": "最高價(留空不限制)",
"keywordsPlaceholder": "請輸入關鍵字,按回車添加",
"maxPositionValuePlaceholder": "留空表示不啟用最大倉位限制",
"delaySecondsHint": "延遲執行模擬真實跟單延遲",
"supportSellHint": "是否跟隨 Leader 賣出",
"sortBy": "排序欄位",
"sortOrder": "排序順序",
"createdAt": "創建時間",
"rerun": "重新測試",
"rerunConfirm": "將按當前配置創建新的回測任務,是否繼續?",
"rerunTaskNamePlaceholder": "新任務名稱(留空使用「原名稱 (副本)」)",
"rerunSuccess": "已創建新回測任務",
"rerunFailed": "重新測試失敗"
}
}
+7
View File
@@ -181,6 +181,13 @@ const AccountDetail: React.FC = () => {
<Descriptions.Item label={t('account.accountName')}>
{account.accountName || '-'}
</Descriptions.Item>
{account.walletType && (
<Descriptions.Item label={t('account.walletType')}>
<Tag color={account.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
{account.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
</Tag>
</Descriptions.Item>
)}
<Descriptions.Item label={t('account.walletAddress')} span={isMobile ? 1 : 2}>
<span style={{
fontFamily: 'monospace',
-2
View File
@@ -35,8 +35,6 @@ const AccountImport: React.FC = () => {
form={form}
onSuccess={handleSuccess}
onCancel={() => navigate('/accounts')}
showAlert={true}
showCancelButton={true}
/>
</Card>
</div>
+32 -5
View File
@@ -303,6 +303,20 @@ const AccountList: React.FC = () => {
)
}
},
{
title: t('accountList.walletType'),
dataIndex: 'walletType',
key: 'walletType',
render: (walletType: string) => {
if (!walletType) return '-'
const type = walletType.toLowerCase()
return (
<Tag color={type === 'magic' ? 'purple' : 'blue'}>
{type === 'magic' ? 'Magic' : 'Safe'}
</Tag>
)
}
},
{
title: t('accountList.balance'),
dataIndex: 'balance',
@@ -392,7 +406,7 @@ const AccountList: React.FC = () => {
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
<div>
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'}
<Button
type="text"
@@ -405,6 +419,14 @@ const AccountList: React.FC = () => {
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',
@@ -654,6 +676,13 @@ const AccountList: React.FC = () => {
/>
</Space>
</Descriptions.Item>
{detailAccount.walletType && (
<Descriptions.Item label={t('accountList.walletType')}>
<Tag color={detailAccount.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
{detailAccount.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
</Tag>
</Descriptions.Item>
)}
<Descriptions.Item label={t('accountList.totalBalance')} span={isMobile ? 1 : 2}>
{detailBalanceLoading ? (
<Spin size="small" />
@@ -854,9 +883,9 @@ const AccountList: React.FC = () => {
accountImportForm.resetFields()
}}
footer={null}
width={isMobile ? '95%' : 600}
width={isMobile ? '95%' : 640}
style={{ top: isMobile ? 20 : 50 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
bodyStyle={{ padding: isMobile ? '16px 20px' : '24px 28px', maxHeight: 'calc(100vh - 140px)', overflow: 'auto' }}
destroyOnClose
maskClosable
closable
@@ -868,8 +897,6 @@ const AccountList: React.FC = () => {
setAccountImportModalVisible(false)
accountImportForm.resetFields()
}}
showAlert={true}
showCancelButton={true}
/>
</Modal>
</div>
+216
View File
@@ -0,0 +1,216 @@
import { useEffect, useRef } from 'react'
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
import { useTranslation } from 'react-i18next'
interface BacktestChartProps {
trades: {
tradeTime: number
balanceAfter: string
}[]
}
const BacktestChart: React.FC<BacktestChartProps> = ({ trades }) => {
const { t } = useTranslation()
const chartRef = useRef<HTMLDivElement>(null)
const chartInstance = useRef<echarts.ECharts | null>(null)
useEffect(() => {
if (!chartRef.current) return
// 初始化图表
chartInstance.current = echarts.init(chartRef.current)
// 监听窗口大小变化
const handleResize = () => {
chartInstance.current?.resize()
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
chartInstance.current?.dispose()
}
}, [])
useEffect(() => {
if (!chartInstance.current || trades.length === 0) return
// 准备数据
const data = trades.map((trade) => ({
time: new Date(trade.tradeTime).toLocaleString(),
value: parseFloat(trade.balanceAfter)
}))
// 初始余额(第一笔交易前的余额)
const initialBalance = data[0]?.value || 0
// 数据压缩:如果数据点太多,进行采样
const maxPoints = 500 // 最多显示500个点
let compressedData = data
if (data.length > maxPoints) {
const step = Math.ceil(data.length / maxPoints)
compressedData = data.filter((_, index) => index % step === 0)
// 确保最后一个点被包含
if (compressedData[compressedData.length - 1] !== data[data.length - 1]) {
compressedData.push(data[data.length - 1])
}
}
const times = compressedData.map(item => item.time)
const values = compressedData.map(item => item.value)
const option: EChartsOption = {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const param = params[0]
const value = parseFloat(param.value).toFixed(2)
const diffValue = param.value - initialBalance
const diff = diffValue.toFixed(2)
const diffPercent = (diffValue / initialBalance * 100).toFixed(2)
const color = diffValue >= 0 ? '#52c41a' : '#ff4d4f'
return `
<div>
<div>${t('backtest.tradeTime')}: ${param.name}</div>
<div>${t('backtest.balanceAfter')}: ${value} USDC</div>
<div style="color: ${color}">
${t('backtest.profitLoss')}: ${diff} USDC (${diffPercent}%)
</div>
</div>
`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '8%',
containLabel: true
},
xAxis: {
type: 'category',
data: times,
axisLabel: {
rotate: 45,
formatter: (value: string) => {
// 简化时间显示,只显示 HH:mm
const parts = value.split(' ')
if (parts.length > 1) {
const timeParts = parts[1].split(':')
if (timeParts.length >= 2) {
return `${timeParts[0]}:${timeParts[1]}`
}
}
return value
}
},
axisLine: {
lineStyle: {
color: '#e0e0e0'
}
},
axisTick: {
alignWithLabel: true,
lineStyle: {
color: '#e0e0e0'
}
}
},
yAxis: {
type: 'value',
name: 'USDC',
nameLocation: 'end',
nameGap: 10,
axisLabel: {
formatter: (value: number) => value.toFixed(2)
},
splitLine: {
lineStyle: {
color: '#f0f0f0'
}
},
axisLine: {
lineStyle: {
color: '#e0e0e0'
}
}
},
series: [
{
name: t('backtest.balanceAfter'),
type: 'line',
data: values,
smooth: true,
symbol: 'circle',
symbolSize: 4,
lineStyle: {
width: 2,
color: '#1890ff'
},
itemStyle: {
color: '#1890ff'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(24, 144, 255, 0.3)' },
{ offset: 1, color: 'rgba(24, 144, 255, 0.05)' }
]
}
},
markLine: {
data: [
{
name: t('backtest.initialBalance'),
yAxis: initialBalance,
label: {
formatter: `${t('backtest.initialBalance')}: ${initialBalance.toFixed(2)}`
},
lineStyle: {
type: 'dashed',
color: '#999',
width: 1
}
}
]
}
}
],
dataZoom: [
{
type: 'inside',
start: 0,
end: 100
},
{
type: 'slider',
start: 0,
end: 100,
height: 20,
bottom: 20
}
]
}
chartInstance.current.setOption(option)
}, [trades, t])
return (
<div
ref={chartRef}
style={{
width: '100%',
height: 400
}}
/>
)
}
export default BacktestChart
+526
View File
@@ -0,0 +1,526 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Card, Descriptions, Button, Tag, Space, Table, message, Row, Col, Statistic, Spin } from 'antd'
import { ArrowLeftOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, CopyOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { formatUSDC } from '../utils'
import { backtestService } from '../services/api'
import type { BacktestTaskDto, BacktestConfigDto, BacktestStatisticsDto, BacktestTradeDto } from '../types/backtest'
import { useMediaQuery } from 'react-responsive'
import BacktestChart from './BacktestChart'
import AddCopyTradingModal from './CopyTradingOrders/AddModal'
const BacktestDetail: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [task, setTask] = useState<BacktestTaskDto | null>(null)
const [config, setConfig] = useState<BacktestConfigDto | null>(null)
const [statistics, setStatistics] = useState<BacktestStatisticsDto | null>(null)
const [trades, setTrades] = useState<BacktestTradeDto[]>([])
const [allTrades, setAllTrades] = useState<BacktestTradeDto[]>([]) // 用于图表显示的所有交易数据
const [tradesLoading, setTradesLoading] = useState(false)
const [tradesTotal, setTradesTotal] = useState(0)
const [tradesPage, setTradesPage] = useState(1)
const [tradesSize] = useState(20)
const [polling, setPolling] = useState<NodeJS.Timeout | null>(null)
// 创建跟单配置 Modal
const [addCopyTradingModalVisible, setAddCopyTradingModalVisible] = useState(false)
const [preFilledConfig, setPreFilledConfig] = useState<any>(null)
// 获取回测任务详情
const fetchTaskDetail = async () => {
setLoading(true)
try {
const response = await backtestService.detail({ id: parseInt(id!) })
if (response.data.code === 0 && response.data.data) {
setTask(response.data.data.task)
setConfig(response.data.data.config)
setStatistics(response.data.data.statistics)
} else {
message.error(response.data.msg || t('backtest.fetchTaskDetailFailed'))
}
} catch (error) {
console.error('Failed to fetch backtest task detail:', error)
message.error(t('backtest.fetchTaskDetailFailed'))
} finally {
setLoading(false)
}
}
// 获取交易记录
const fetchTrades = async (page: number) => {
setTradesLoading(true)
try {
const response = await backtestService.trades({
taskId: parseInt(id!),
page,
size: tradesSize
})
if (response.data.code === 0 && response.data.data) {
setTrades(response.data.data.list)
setTradesTotal(response.data.data.total)
} else {
message.error(response.data.msg || t('backtest.fetchTradesFailed'))
}
} catch (error) {
console.error('Failed to fetch backtest trades:', error)
message.error(t('backtest.fetchTradesFailed'))
} finally {
setTradesLoading(false)
}
}
// 获取所有交易记录(用于图表显示)
const fetchAllTrades = async () => {
try {
const response = await backtestService.trades({
taskId: parseInt(id!),
page: 1,
size: 10000 // 获取所有数据
})
if (response.data.code === 0 && response.data.data) {
setAllTrades(response.data.data.list)
}
} catch (error) {
console.error('Failed to fetch all trades for chart:', error)
}
}
// 初始加载任务详情和交易记录
useEffect(() => {
fetchTaskDetail()
fetchTrades(tradesPage)
fetchAllTrades() // 加载所有交易数据用于图表
}, [id])
// 根据任务状态控制轮询
useEffect(() => {
// 停止之前的轮询
stopPolling()
// 只有任务正在运行或待处理时才启动轮询
if (task?.status === 'RUNNING' || task?.status === 'PENDING') {
const timer = setInterval(() => {
fetchTaskDetail()
}, 3000) // 每3秒轮询一次
setPolling(timer)
}
// 组件卸载或状态变化时清理定时器
return () => {
stopPolling()
}
}, [task?.status])
const stopPolling = () => {
if (polling) {
clearInterval(polling)
setPolling(null)
}
}
// 返回
const handleBack = () => {
navigate('/backtest')
}
// 停止任务
const handleStop = () => {
if (!window.confirm(t('backtest.stopConfirm'))) return
const stop = async () => {
try {
const response = await backtestService.stop({ id: parseInt(id!) })
if (response.data.code === 0) {
message.success(t('backtest.stopSuccess'))
fetchTaskDetail()
stopPolling()
} else {
message.error(response.data.msg || t('backtest.stopFailed'))
}
} catch (error) {
console.error('Failed to stop backtest task:', error)
message.error(t('backtest.stopFailed'))
}
}
stop()
}
// 删除任务
const handleDelete = () => {
if (!window.confirm(t('backtest.deleteConfirm'))) return
const del = async () => {
try {
const response = await backtestService.delete({ id: parseInt(id!) })
if (response.data.code === 0) {
message.success(t('backtest.deleteSuccess'))
stopPolling() // 停止轮询
navigate('/backtest')
} else {
message.error(response.data.msg || t('backtest.deleteFailed'))
}
} catch (error) {
console.error('Failed to delete backtest task:', error)
message.error(t('backtest.deleteFailed'))
}
}
del()
}
// 刷新
const handleRefresh = () => {
fetchTaskDetail()
fetchTrades(tradesPage)
}
// 一键创建跟单配置
const handleCreateCopyTrading = () => {
console.log('[BacktestDetail] handleCreateCopyTrading called, task:', task, 'config:', config)
if (!task || !config) {
console.log('[BacktestDetail] No task or config available')
return
}
// 预填充回测任务的配置参数(从 config 中获取)
const preFilled = {
leaderId: task.leaderId,
copyMode: config.copyMode,
copyRatio: config.copyMode === 'RATIO' ? parseFloat(config.copyRatio) * 100 : undefined,
fixedAmount: config.copyMode === 'FIXED' ? config.fixedAmount : undefined,
maxOrderSize: parseFloat(config.maxOrderSize),
minOrderSize: parseFloat(config.minOrderSize),
maxDailyLoss: parseFloat(config.maxDailyLoss),
maxDailyOrders: config.maxDailyOrders,
supportSell: config.supportSell,
keywordFilterMode: config.keywordFilterMode || 'DISABLED',
keywords: config.keywords || [],
configName: `回测任务-${task.taskName}`
}
console.log('[BacktestDetail] Generated preFilled config:', preFilled)
console.log('[BacktestDetail] Setting preFilledConfig and opening modal')
setPreFilledConfig(preFilled)
setAddCopyTradingModalVisible(true)
}
// 状态标签颜色
const getStatusColor = (status: string) => {
switch (status) {
case 'PENDING': return 'blue'
case 'RUNNING': return 'processing'
case 'COMPLETED': return 'success'
case 'STOPPED': return 'warning'
case 'FAILED': return 'error'
default: return 'default'
}
}
// 状态标签文本
const getStatusText = (status: string) => {
switch (status) {
case 'PENDING': return t('backtest.statusPending')
case 'RUNNING': return t('backtest.statusRunning')
case 'COMPLETED': return t('backtest.statusCompleted')
case 'STOPPED': return t('backtest.statusStopped')
case 'FAILED': return t('backtest.statusFailed')
default: return status
}
}
const columns = [
{
title: t('backtest.tradeTime'),
dataIndex: 'tradeTime',
key: 'tradeTime',
width: 180,
render: (timestamp: number) => new Date(timestamp).toLocaleString()
},
{
title: t('backtest.marketTitle'),
dataIndex: 'marketTitle',
key: 'marketTitle',
width: 250,
ellipsis: true
},
{
title: t('backtest.side'),
dataIndex: 'side',
key: 'side',
width: 100,
render: (side: string) => (
<Tag color={side === 'BUY' ? 'green' : side === 'SELL' ? 'orange' : 'blue'}>
{side === 'BUY' ? t('backtest.sideBuy') : side === 'SELL' ? t('backtest.sideSell') : t('backtest.sideSettlement')}
</Tag>
)
},
{
title: t('backtest.outcome'),
dataIndex: 'outcome',
key: 'outcome',
width: 100
},
{
title: t('backtest.quantity'),
dataIndex: 'quantity',
key: 'quantity',
width: 100,
render: (value: string) => parseFloat(value).toFixed(4)
},
{
title: t('backtest.price'),
dataIndex: 'price',
key: 'price',
width: 100,
render: (value: string) => parseFloat(value).toFixed(4)
},
{
title: t('backtest.amount') + ' (USDC)',
dataIndex: 'amount',
key: 'amount',
width: 120,
render: (value: string) => formatUSDC(value)
},
{
title: t('backtest.balanceAfter') + ' (USDC)',
dataIndex: 'balanceAfter',
key: 'balanceAfter',
width: 120,
render: (value: string) => formatUSDC(value)
},
{
title: t('backtest.leaderTradeId'),
dataIndex: 'leaderTradeId',
key: 'leaderTradeId',
width: 150,
ellipsis: true
}
]
if (!task) {
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>
}
return (
<div style={{ padding: 24 }}>
<Card>
{/* 头部操作栏 */}
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={handleBack} size={isMobile ? 'middle' : 'large'}>
{t('common.back')}
</Button>
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={loading} size={isMobile ? 'middle' : 'large'}>
{t('common.refresh')}
</Button>
</Space>
<Space>
{task.status === 'COMPLETED' && (
<Button type="primary" icon={<CopyOutlined />} onClick={handleCreateCopyTrading} size={isMobile ? 'middle' : 'large'}>
{t('backtest.createCopyTrading')}
</Button>
)}
{(task.status === 'RUNNING' || task.status === 'PENDING') && (
<Button danger icon={<StopOutlined />} onClick={handleStop} size={isMobile ? 'middle' : 'large'}>
{t('backtest.stop')}
</Button>
)}
{(task.status === 'COMPLETED' || task.status === 'STOPPED' || task.status === 'FAILED') && (
<Button danger icon={<DeleteOutlined />} onClick={handleDelete} size={isMobile ? 'middle' : 'large'}>
{t('common.delete')}
</Button>
)}
</Space>
</Space>
{/* 任务基本信息 */}
<Card title={t('backtest.taskDetail')} size="small">
<Descriptions column={isMobile ? 1 : 2} bordered size="small">
<Descriptions.Item label={t('backtest.taskName')}>{task.taskName}</Descriptions.Item>
<Descriptions.Item label={t('backtest.leader')}>
{task.leaderName || task.leaderAddress}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.initialBalance')}>
{formatUSDC(task.initialBalance)} USDC
</Descriptions.Item>
<Descriptions.Item label={t('backtest.finalBalance')}>
{task.finalBalance ? formatUSDC(task.finalBalance) + ' USDC' : '-'}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.profitAmount')}>
<span style={{ color: task.profitAmount && parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>
{task.profitAmount ? formatUSDC(task.profitAmount) + ' USDC' : '-'}
</span>
</Descriptions.Item>
<Descriptions.Item label={t('backtest.profitRate')}>
<span style={{ color: task.profitRate && parseFloat(task.profitRate) >= 0 ? '#52c41a' : '#ff4d4f' }}>
{task.profitRate ? task.profitRate + '%' : '-'}
</span>
</Descriptions.Item>
<Descriptions.Item label={t('backtest.backtestDays')}>
{task.backtestDays} {t('common.day')}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.status')}>
<Tag color={getStatusColor(task.status)}>{getStatusText(task.status)}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('backtest.progress')}>
{task.progress}%
</Descriptions.Item>
<Descriptions.Item label={t('backtest.totalTrades')}>
{task.totalTrades}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.startTime')}>
{new Date(task.startTime).toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.endTime')}>
{task.endTime ? new Date(task.endTime).toLocaleString() : '-'}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.createdAt')}>
{new Date(task.createdAt).toLocaleString()}
</Descriptions.Item>
</Descriptions>
</Card>
{/* 统计信息 */}
{statistics && (
<Row gutter={16}>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.buyTrades')}
value={statistics.buyTrades}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.sellTrades')}
value={statistics.sellTrades}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.winTrades')}
value={statistics.winTrades}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.lossTrades')}
value={statistics.lossTrades}
valueStyle={{ color: '#ff4d4f' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.winRate')}
value={statistics.winRate}
suffix="%"
valueStyle={{ color: parseFloat(statistics.winRate) >= 50 ? '#52c41a' : '#ff4d4f' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.maxProfit')}
value={formatUSDC(statistics.maxProfit)}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.maxLoss')}
value={formatUSDC(statistics.maxLoss)}
valueStyle={{ color: '#ff4d4f' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.maxDrawdown')}
value={formatUSDC(statistics.maxDrawdown)}
valueStyle={{ color: '#ff4d4f' }}
/>
</Card>
</Col>
{statistics.avgHoldingTime && (
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.avgHoldingTime')}
value={(statistics.avgHoldingTime / 1000 / 60).toFixed(2)}
suffix=" min"
/>
</Card>
</Col>
)}
</Row>
)}
{/* 资金变化图表 */}
{allTrades.length > 0 && (
<Card title={t('backtest.balanceChart')}>
<BacktestChart trades={allTrades} />
</Card>
)}
{/* 交易记录 */}
<Card title={t('backtest.tradeRecords')}>
<Table
columns={columns}
dataSource={trades}
rowKey="id"
loading={tradesLoading}
pagination={{
current: tradesPage,
pageSize: tradesSize,
total: tradesTotal,
showSizeChanger: false,
showTotal: (total) => `${t('common.total')} ${total} ${t('common.items')}`,
onChange: (newPage) => {
setTradesPage(newPage)
fetchTrades(newPage)
}
}}
scroll={isMobile ? { x: 1200 } : { x: 1800 }}
/>
</Card>
</Space>
</Card>
{/* 创建跟单配置 Modal */}
<AddCopyTradingModal
open={addCopyTradingModalVisible}
onClose={() => {
setAddCopyTradingModalVisible(false)
setPreFilledConfig(null)
}}
onSuccess={() => {
message.success(t('backtest.createCopyTradingSuccess'))
setAddCopyTradingModalVisible(false)
setPreFilledConfig(null)
}}
preFilledConfig={preFilledConfig}
/>
</div>
)
}
export default BacktestDetail
File diff suppressed because it is too large Load Diff
+27 -11
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Divider, Spin } from 'antd'
import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
@@ -13,11 +14,13 @@ import StatisticsModal from './CopyTradingOrders/StatisticsModal'
import FilteredOrdersModal from './CopyTradingOrders/FilteredOrdersModal'
import EditModal from './CopyTradingOrders/EditModal'
import AddModal from './CopyTradingOrders/AddModal'
import LeaderSelect from '../components/LeaderSelect'
const { Option } = Select
const CopyTradingList: React.FC = () => {
const { t } = useTranslation()
const [searchParams] = useSearchParams()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
const [copyTradings, setCopyTradings] = useState<CopyTrading[]>([])
@@ -29,8 +32,15 @@ const CopyTradingList: React.FC = () => {
accountId?: number
leaderId?: number
enabled?: boolean
}>({})
}>(() => {
const leaderIdParam = searchParams.get('leaderId')
if (leaderIdParam) {
const leaderId = parseInt(leaderIdParam, 10)
if (!isNaN(leaderId)) return { leaderId }
}
return {}
})
// Modal 状态
const [ordersModalOpen, setOrdersModalOpen] = useState(false)
const [ordersModalCopyTradingId, setOrdersModalCopyTradingId] = useState<string>('')
@@ -43,12 +53,23 @@ const CopyTradingList: React.FC = () => {
const [editModalCopyTradingId, setEditModalCopyTradingId] = useState<string>('')
const [addModalOpen, setAddModalOpen] = useState(false)
// 从 URL 读取 leaderId 并应用筛选(如从 Leader 管理页跳转过来)
useEffect(() => {
const leaderIdParam = searchParams.get('leaderId')
if (leaderIdParam) {
const leaderId = parseInt(leaderIdParam, 10)
if (!isNaN(leaderId)) {
setFilters(prev => ({ ...prev, leaderId }))
}
}
}, [searchParams])
useEffect(() => {
fetchAccounts()
fetchLeaders()
fetchCopyTradings()
}, [])
useEffect(() => {
fetchCopyTradings()
}, [filters])
@@ -390,19 +411,14 @@ const CopyTradingList: React.FC = () => {
))}
</Select>
<Select
<LeaderSelect
placeholder={t('copyTradingList.filterLeader') || '筛选 Leader'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.leaderId}
onChange={(value) => setFilters({ ...filters, leaderId: value || undefined })}
>
{leaders.map(leader => (
<Option key={leader.id} value={leader.id}>
{leader.leaderName || `Leader ${leader.id}`}
</Option>
))}
</Select>
leaders={leaders}
/>
<Select
placeholder="筛选状态"
+117 -45
View File
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import AccountImportForm from '../../components/AccountImportForm'
import LeaderAddForm from '../../components/LeaderAddForm'
import LeaderSelect from '../../components/LeaderSelect'
const { Option } = Select
@@ -16,12 +17,28 @@ interface AddModalProps {
open: boolean
onClose: () => void
onSuccess?: () => void
preFilledConfig?: {
leaderId?: number
copyMode?: 'RATIO' | 'FIXED'
copyRatio?: number
fixedAmount?: string
maxOrderSize?: number
minOrderSize?: number
maxDailyLoss?: number
maxDailyOrders?: number
supportSell?: boolean
keywordFilterMode?: string
keywords?: string[]
maxPositionValue?: number
configName?: string
}
}
const AddModal: React.FC<AddModalProps> = ({
open,
onClose,
onSuccess
onSuccess,
preFilledConfig
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
@@ -64,25 +81,113 @@ const AddModal: React.FC<AddModalProps> = ({
return `跟单配置-${dateStr}-${timeStr}`
}
// 获取 Leader 资产信息
const fetchLeaderAssetInfo = async (leaderId: number) => {
if (!leaderId) return
setLoadingAssetInfo(true)
setLeaderAssetInfo(null)
try {
const response = await apiService.leaders.balance({ leaderId })
if (response.data.code === 0 && response.data.data) {
const balance = response.data.data
setLeaderAssetInfo({
total: balance.totalBalance || '0',
available: balance.availableBalance || '0',
position: balance.positionBalance || '0'
})
} else {
message.error(response.data.msg || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
}
} catch (error: any) {
console.error('获取 Leader 资产失败:', error)
message.error(error.message || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
} finally {
setLoadingAssetInfo(false)
}
}
// 填充预配置数据到表单(复用模板填充逻辑)
const fillPreFilledConfig = (config: typeof preFilledConfig) => {
console.log('[AddModal] fillPreFilledConfig called with config:', config)
if (!config) {
console.log('[AddModal] fillPreFilledConfig: config is null/undefined')
return
}
const formValues = {
configName: config.configName || generateDefaultConfigName(),
leaderId: config.leaderId,
copyMode: config.copyMode || 'RATIO',
copyRatio: config.copyRatio,
fixedAmount: config.fixedAmount,
maxOrderSize: config.maxOrderSize,
minOrderSize: config.minOrderSize,
maxDailyLoss: config.maxDailyLoss,
maxDailyOrders: config.maxDailyOrders,
supportSell: config.supportSell,
keywordFilterMode: config.keywordFilterMode || 'DISABLED',
maxPositionValue: config.maxPositionValue
}
console.log('[AddModal] fillPreFilledConfig: setting form values:', formValues)
form.setFieldsValue(formValues)
setCopyMode(config.copyMode || 'RATIO')
setKeywords(config.keywords || [])
console.log('[AddModal] fillPreFilledConfig: form values set, copyMode:', config.copyMode, 'keywords:', config.keywords)
// 自动获取 Leader 资产信息
if (config.leaderId) {
console.log('[AddModal] fillPreFilledConfig: fetching leader asset info for leaderId:', config.leaderId)
fetchLeaderAssetInfo(config.leaderId)
}
}
// 处理 Modal 打开/关闭
useEffect(() => {
console.log('[AddModal] useEffect triggered, open:', open, 'preFilledConfig:', preFilledConfig)
if (open) {
console.log('[AddModal] Modal opened, fetching accounts, leaders, templates')
fetchAccounts()
fetchLeaders()
fetchTemplates()
// 生成默认配置名
// 如果有预填充配置,填充表单(延迟执行确保数据已加载)
if (preFilledConfig) {
console.log('[AddModal] preFilledConfig exists, will fill form after 100ms')
// 使用 setTimeout 确保在下一个事件循环执行,此时 Modal 已完全打开
setTimeout(() => {
console.log('[AddModal] setTimeout callback executed, calling fillPreFilledConfig')
fillPreFilledConfig(preFilledConfig)
}, 100)
} else {
console.log('[AddModal] No preFilledConfig, using default values')
// 没有预填充配置时,生成默认配置名
const defaultConfigName = generateDefaultConfigName()
form.setFieldsValue({ configName: defaultConfigName })
// 重置关键字列表
form.setFieldsValue({
configName: defaultConfigName,
copyMode: 'RATIO',
copyRatio: 100,
maxOrderSize: 1000,
minOrderSize: 1,
maxDailyLoss: 10000,
maxDailyOrders: 100,
supportSell: true,
keywordFilterMode: 'DISABLED'
})
setCopyMode('RATIO')
setKeywords([])
}
} else {
console.log('[AddModal] Modal closed, resetting form')
// 关闭时重置表单
form.resetFields()
setKeywords([])
setCopyMode('RATIO')
setLeaderAssetInfo(null)
}
}, [open])
}, [open, preFilledConfig])
const fetchLeaders = async () => {
try {
@@ -133,32 +238,6 @@ const AddModal: React.FC<AddModalProps> = ({
setCopyMode(mode)
}
// 获取 Leader 资产信息
const fetchLeaderAssetInfo = async (leaderId: number) => {
if (!leaderId) return
setLoadingAssetInfo(true)
setLeaderAssetInfo(null)
try {
const response = await apiService.leaders.balance({ leaderId })
if (response.data.code === 0 && response.data.data) {
const balance = response.data.data
setLeaderAssetInfo({
total: balance.totalBalance || '0',
available: balance.availableBalance || '0',
position: balance.positionBalance || '0'
})
} else {
message.error(response.data.msg || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
}
} catch (error: any) {
console.error('获取 Leader 资产失败:', error)
message.error(error.message || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
} finally {
setLoadingAssetInfo(false)
}
}
// 处理导入账户成功
const handleAccountImportSuccess = async (accountId: number) => {
message.success(t('accountImport.importSuccess'))
@@ -391,8 +470,10 @@ const AddModal: React.FC<AddModalProps> = ({
name="leaderId"
rules={[{ required: true, message: t('copyTradingAdd.leaderRequired') || '请选择 Leader' }]}
>
<Select
<LeaderSelect
leaders={leaders}
placeholder={t('copyTradingAdd.selectLeaderPlaceholder') || '请选择 Leader'}
onSelectChange={(value) => value !== undefined && fetchLeaderAssetInfo(value)}
notFoundContent={
leaders.length === 0 ? (
<div style={{ textAlign: 'center', padding: '12px' }}>
@@ -408,14 +489,7 @@ const AddModal: React.FC<AddModalProps> = ({
</div>
) : null
}
onChange={(value) => fetchLeaderAssetInfo(value)}
>
{leaders.map(leader => (
<Option key={leader.id} value={leader.id}>
{leader.leaderName || `Leader ${leader.id}`} ({leader.leaderAddress.slice(0, 6)}...{leader.leaderAddress.slice(-4)})
</Option>
))}
</Select>
/>
</Form.Item>
{/* Leader 资产信息 */}
@@ -1032,9 +1106,9 @@ const AddModal: React.FC<AddModalProps> = ({
accountImportForm.resetFields()
}}
footer={null}
width={isMobile ? '95%' : 600}
width={isMobile ? '95%' : 640}
style={{ top: isMobile ? 20 : 50 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
bodyStyle={{ padding: isMobile ? '16px 20px' : '24px 28px', maxHeight: 'calc(100vh - 140px)', overflow: 'auto' }}
destroyOnClose
maskClosable
closable
@@ -1046,8 +1120,6 @@ const AddModal: React.FC<AddModalProps> = ({
setAccountImportModalVisible(false)
accountImportForm.resetFields()
}}
showAlert={true}
showCancelButton={true}
/>
</Modal>
@@ -323,7 +323,10 @@ const EditModal: React.FC<EditModalProps> = ({
>
<Select disabled>
<Option value={copyTrading.leaderId}>
{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`} ({copyTrading.leaderAddress.slice(0, 6)}...{copyTrading.leaderAddress.slice(-4)})
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span>{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`}</span>
<span style={{ fontSize: '12px', color: '#999' }}>{copyTrading.leaderAddress}</span>
</div>
</Option>
</Select>
</Form.Item>
+50 -6
View File
@@ -260,7 +260,34 @@ const LeaderList: React.FC = () => {
dataIndex: 'copyTradingCount',
key: 'copyTradingCount',
width: 100,
render: (count: number) => <Tag color="cyan">{count}</Tag>
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'),
@@ -345,7 +372,24 @@ const LeaderList: React.FC = () => {
<Divider style={{ margin: '12px 0' }} />
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
<Tag color="cyan">{leader.copyTradingCount} {t('leaderList.copyTradingCount')}</Tag>
<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>
{leader.remark && (
@@ -356,15 +400,15 @@ const LeaderList: React.FC = () => {
)}
<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' }}>
<Button type="primary" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(leader)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('common.viewDetail')}
</Button>
{leader.website && (
<Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
<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' }}>
{t('leaderList.openWebsite')}
</Button>
)}
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('common.edit')}
</Button>
<Popconfirm
@@ -374,7 +418,7 @@ const LeaderList: React.FC = () => {
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('common.delete')}
</Button>
</Popconfirm>
+18 -4
View File
@@ -62,10 +62,10 @@ const PositionList: React.FC = () => {
}
}, [])
// 当仓位数据变化时,更新可赎回统计
// 当仓位数据变化时,静默更新可赎回统计(不显示loading状态)
useEffect(() => {
if (currentPositions.length > 0) {
fetchRedeemableSummary()
fetchRedeemableSummarySilently()
}
}, [currentPositions, selectedAccountId])
@@ -74,7 +74,19 @@ const PositionList: React.FC = () => {
setCurrentPage(1)
}, [positionFilter, selectedAccountId, searchKeyword])
// 获取可赎回仓位统计
// 静默获取可赎回仓位统计(不显示loading状态)
const fetchRedeemableSummarySilently = async () => {
try {
const response = await apiService.accounts.getRedeemableSummary({ accountId: selectedAccountId })
if (response.data.code === 0 && response.data.data) {
setRedeemableSummary(response.data.data)
}
} catch (error: any) {
console.error('获取可赎回统计失败:', error)
}
}
// 获取可赎回仓位统计(带loading状态,用于用户主动操作)
const fetchRedeemableSummary = async () => {
setLoadingRedeemableSummary(true)
try {
@@ -91,8 +103,9 @@ const PositionList: React.FC = () => {
// 处理赎回按钮点击
const handleRedeemClick = async () => {
await fetchRedeemableSummary()
setRedeemModalVisible(true)
// 打开模态框时重新获取最新数据
fetchRedeemableSummary()
}
// 提交赎回
@@ -875,6 +888,7 @@ const PositionList: React.FC = () => {
: null
const handleTitleClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')
+85
View File
@@ -206,6 +206,12 @@ export const apiService = {
* API
*/
accounts: {
/**
*
*/
checkProxyOptions: (data: any) =>
apiClient.post<ApiResponse<any>>('/accounts/check-proxy-options', data),
/**
*
*/
@@ -726,3 +732,82 @@ export { apiClient }
export default apiService
/**
*
*/
export const backtestService = {
/**
*
*/
create: (data: {
taskName: string
leaderId: number
initialBalance: string
backtestDays: number
copyMode?: 'RATIO' | 'FIXED'
copyRatio?: string
fixedAmount?: string
maxOrderSize?: string
minOrderSize?: string
maxDailyLoss?: string
maxDailyOrders?: number
priceTolerance?: string
delaySeconds?: number
supportSell?: boolean
minOrderDepth?: string
maxSpread?: string
minPrice?: string
maxPrice?: string
maxPositionValue?: string
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST'
keywords?: string[]
maxMarketEndDate?: number | null
}) => apiClient.post('/backtest/tasks', data),
/**
*
*/
list: (data: {
leaderId?: number
status?: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED'
sortBy?: 'profitAmount' | 'profitRate' | 'createdAt'
sortOrder?: 'asc' | 'desc'
page: number
size: number
}) => apiClient.post('/backtest/tasks/list', data),
/**
*
*/
detail: (data: { id: number }) => apiClient.post('/backtest/tasks/detail', data),
/**
*
*/
trades: (data: {
taskId: number
page: number
size: number
}) => apiClient.post('/backtest/tasks/trades', data),
/**
*
*/
stop: (data: { id: number }) => apiClient.post('/backtest/tasks/stop', data),
/**
*
*/
delete: (data: { id: number }) => apiClient.post('/backtest/tasks/delete', data),
/**
*
*/
retry: (data: { id: number }) => apiClient.post('/backtest/tasks/retry', data),
/**
*
*/
rerun: (data: { id: number; taskName?: string }) => apiClient.post('/backtest/tasks/rerun', data)
}
+3 -1
View File
@@ -60,8 +60,10 @@ export const useAccountStore = create<AccountStore>((set, get) => ({
if (response.data.code === 0) {
await get().fetchAccounts()
} else {
const err = new Error(response.data.msg || '导入账户失败')
;(err as Error & { code?: number }).code = response.data.code
set({ error: response.data.msg || '导入账户失败', loading: false })
throw new Error(response.data.msg || '导入账户失败')
throw err
}
} catch (error: any) {
set({ error: error.message || '导入账户失败', loading: false })
+218
View File
@@ -0,0 +1,218 @@
/**
*
*/
/**
*
*/
export interface BacktestCreateRequest {
taskName: string
leaderId: number
initialBalance: string
backtestDays: number // 1-30
// 跟单配置
copyMode?: 'RATIO' | 'FIXED'
copyRatio?: string
fixedAmount?: string
maxOrderSize?: string
minOrderSize?: string
maxDailyLoss?: string
maxDailyOrders?: number
priceTolerance?: string // 百分比
delaySeconds?: number
supportSell?: boolean
minOrderDepth?: string
maxSpread?: string
minPrice?: string
maxPrice?: string
maxPositionValue?: string
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST'
keywords?: string[]
maxMarketEndDate?: number | null
pageForResume?: number // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
}
/**
*
*/
export interface BacktestListRequest {
leaderId?: number
status?: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED'
sortBy?: 'profitAmount' | 'profitRate' | 'createdAt'
sortOrder?: 'asc' | 'desc'
page: number
size: number
pageForResume?: number // 恢复时从指定页码开始
}
/**
*
*/
export interface BacktestDetailRequest {
id: number
}
/**
*
*/
export interface BacktestTradeListRequest {
taskId: number
page: number
size: number
}
/**
*
*/
export interface BacktestProgressRequest {
id: number
}
/**
*
*/
export interface BacktestStopRequest {
id: number
}
/**
*
*/
export interface BacktestDeleteRequest {
id: number
}
/**
*
*/
export interface BacktestRetryRequest {
id: number
}
/**
*
*/
export interface BacktestListResponse {
list: BacktestTaskDto[]
total: number
page: number
size: number
processedTradeCount?: number // 已处理的交易数量(用于显示真实进度)
}
/**
*
*/
export interface BacktestDetailResponse {
task: BacktestTaskDto
config: BacktestConfigDto
statistics: BacktestStatisticsDto
lastProcessedTradeTime?: number // 最后处理的交易时间(用于中断恢复)
lastProcessedTradeIndex?: number // 最后处理的交易索引(用于中断恢复)
processedTradeCount?: number // 已处理的交易数量(用于显示真实进度)
}
/**
*
*/
export interface BacktestTradeListResponse {
list: BacktestTradeDto[]
total: number
page: number
size: number
}
/**
*
*/
export interface BacktestProgressResponse {
progress: number // 0-100
currentBalance: string
totalTrades: number
status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED'
}
/**
* DTO
*/
export interface BacktestTaskDto {
id: number
taskName: string
leaderId: number
leaderName: string | null
leaderAddress: string | null
initialBalance: string
finalBalance: string | null
profitAmount: string | null
profitRate: string | null // 百分比
backtestDays: number
startTime: number
endTime: number | null
status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED'
progress: number // 0-100
totalTrades: number
createdAt: number
executionStartedAt: number | null
executionFinishedAt: number | null
}
/**
* DTO
*/
export interface BacktestConfigDto {
copyMode: 'RATIO' | 'FIXED'
copyRatio: string
fixedAmount: string | null
maxOrderSize: string
minOrderSize: string
maxDailyLoss: string
maxDailyOrders: number
priceTolerance: string // 百分比
delaySeconds: number
supportSell: boolean
minOrderDepth: string | null
maxSpread: string | null
minPrice: string | null
maxPrice: string | null
maxPositionValue: string | null
keywordFilterMode: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' | null
keywords: string[] | null
maxMarketEndDate: number | null
}
/**
* DTO
*/
export interface BacktestStatisticsDto {
totalTrades: number // 总交易笔数
buyTrades: number // 买入笔数
sellTrades: number // 卖出笔数
winTrades: number // 盈利交易笔数
lossTrades: number // 亏损交易笔数
winRate: string // 胜率 (百分比)
maxProfit: string // 最大单笔盈利
maxLoss: string // 最大单笔亏损
maxDrawdown: string // 最大回撤
avgHoldingTime: number | null // 平均持仓时间 (毫秒)
}
/**
* DTO
*/
export interface BacktestTradeDto {
id: number
tradeTime: number
marketId: string
marketTitle: string | null
side: 'BUY' | 'SELL' | 'SETTLEMENT'
outcome: string // YES/NO 或 outcomeIndex
outcomeIndex: number | null
quantity: string
price: string
amount: string
fee: string
profitLoss: string | null // 仅卖出和结算时有值
balanceAfter: string
leaderTradeId: string | null
}
+56
View File
@@ -46,6 +46,37 @@ export interface AccountImportRequest {
walletType?: string // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
}
/**
*
*/
export interface CheckProxyOptionsRequest {
walletAddress: string // EOA 地址
privateKey?: string // 私钥(加密,私钥导入时提供)
mnemonic?: string // 助记词(加密,助记词导入时提供)
}
/**
*
*/
export interface ProxyOption {
walletType: string // "magic" 或 "safe"
proxyAddress: string // 代理地址
descriptionKey: string // 说明文案的多语言 key
availableBalance: string // 可用余额
positionBalance: string // 仓位余额
totalBalance: string // 总余额
positionCount: number // 持仓数量
hasAssets: boolean // 是否有资产
error?: string // 获取失败时的错误信息
}
/**
*
*/
export interface CheckProxyOptionsResponse {
options: ProxyOption[] // 代理地址选项列表
}
/**
*
*/
@@ -65,6 +96,7 @@ export interface Leader {
remark?: string // Leader 备注(可选)
website?: string // Leader 网站(可选)
copyTradingCount: number
backtestCount: number // 回测数量
totalOrders?: number
totalPnl?: string
createdAt: number
@@ -978,3 +1010,27 @@ export interface NodeCheckResult {
responseTimeMs?: number
blockNumber?: string
}
/**
* DTO
*/
export interface BacktestTaskDto {
id: number
taskName: string
leaderId: number
leaderName?: string
leaderAddress?: string
initialBalance: string
finalBalance?: string
profitAmount?: string
profitRate?: string
backtestDays: number
startTime: number
endTime?: number
status: string // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
progress: number
totalTrades: number
createdAt: number
executionStartedAt?: number
executionFinishedAt?: number
}
+2 -1
View File
@@ -4,7 +4,8 @@
"description": "Utility scripts for Polyhermes",
"type": "module",
"scripts": {
"get-order-detail": "node get-order-detail.js"
"get-order-detail": "node get-order-detail.js",
"verify-backtest-data": "node verify-backtest-data.js"
},
"dependencies": {
"@ethersproject/wallet": "^5.7.0",