Compare commits

...

42 Commits

Author SHA1 Message Date
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 f1eae8a40b chore: 移除 Telegram PR 合并通知工作流 2026-01-31 00:59:45 +08:00
WrBug ca655f351d docs: 添加 v2.0.3 发布说明 2026-01-31 00:57:45 +08:00
WrBug 40081c2464 feat: 为所有数值显示添加千分位分隔符
- 重构 formatNumber 和 formatUSDC 函数,默认添加千分位分隔符
- 更新 Statistics.tsx、CopyTradingStatistics.tsx 等统计页面
- 更新 PositionList.tsx 持仓列表的数值显示
- 更新 AccountList.tsx 账户列表的余额显示
- 所有数值(金额、数量、价格等)现在默认显示千分位
- 示例:1234567.89 显示为 1,234,567.89

影响范围:
- 工具函数:utils/index.ts
- 统计页面:Statistics.tsx, CopyTradingStatistics.tsx
- 业务页面:PositionList.tsx, AccountList.tsx

Changes:
- Refactored formatNumber and formatUSDC to include thousand separators by default
- Updated statistics pages to display numbers with commas
- Updated position list and account list for better readability
- Example: 1234567.89 now displays as 1,234,567.89
2026-01-31 00:52:42 +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 e8fd1b503b 移除跟单最大仓位数量(maxPositionCount)配置
- 数据库:创建迁移文件 V26 删除 max_position_count 字段
- 后端:移除实体类、DTO、服务中的 maxPositionCount 相关代码
- 后端:移除 FilterResult 中的 FAILED_MAX_POSITION_COUNT 状态
- 后端:移除 CopyTradingFilterService 中的最大仓位数量检查逻辑
- 前端:移除类型定义、表单字段和国际化翻译
- 前端:移除过滤订单列表中的 MAX_POSITION_COUNT 类型
- 前端:修复编译错误(Spin 导入、Table fixed 类型、size 类型)
2026-01-31 00:24:25 +08:00
WrBug 390b3ee876 feat: 在创建跟单配置时显示Leader资产信息
- 选择Leader后自动获取并显示资产信息(总资产、可用余额、仓位资产)
- 在AddModal和EditModal中都实现该功能
- 使用Card和Statistic组件美观展示
- 添加中英文多语言支持
- 使用formatUSDC格式化显示金额
2026-01-30 23:58:18 +08:00
WrBug 80976609c7 fix: 修复TypeScript类型错误
- 移除未使用的PositionDto导入
- 修复pnl参数重复声明
2026-01-30 23:29:42 +08:00
WrBug 17eea0183f refactor: 列表只显示可用余额
- 桌面和移动端都只显示可用余额
- 移除总余额显示
2026-01-30 23:13:26 +08:00
WrBug 6980781f89 refactor: 移除仓位资产列
- 列表只显示总余额和跟单数
- 移除仓位资产列
- 移动端卡片移除仓位资产Tag
2026-01-30 22:42:44 +08:00
WrBug 3350039f05 feat: Leader列表显示仓位资产
- 列表新增仓位资产列
- 移动端卡片新增仓位资产Tag
- 文案'仓位余额'改为'仓位资产'
- 仓位资产使用紫色Tag显示
2026-01-30 22:24:59 +08:00
WrBug 0bdc0c74d1 feat: Leader列表优化
- 后端过滤价值为0的仓位
- 持仓列表显示市场名称而非ID
- 列表移除分类和创建时间列
- 文案'跟单关系数'改为'跟单数'
- 持仓DTO添加title字段
2026-01-30 22:03:50 +08:00
WrBug cccc829cef Merge pull request #25 from WrBug/dev
feat: 优化构建流程和通知机制
2026-01-29 04:09:44 +08:00
WrBug fa1a915e9c docs: 添加 v2.0.2 release notes 2026-01-29 04:00:11 +08:00
WrBug e5992b5145 feat(workflow): 优化构建流程和通知机制
- 添加 workflow_dispatch 支持手动触发构建
- Release 事件默认只打包产物(在线升级),不构建 Docker
- 手动触发可选择构建类型:在线升级或 Docker 升级
- 优化 Telegram 通知文案,区分构建类型和升级路径
- 在线升级任务不发送开始通知,只发送完成通知
- 手动触发时产物上传为 Artifact,Release 事件上传到 Release Assets
2026-01-29 03:59:07 +08:00
WrBug 42472f6b03 fix: 修复买入订单金额精度问题
- 修复市场买入订单 makerAmount 和 takerAmount 的精度限制
- makerAmount (USDC) 限制为最多 2 位小数
- takerAmount (shares) 限制为最多 4 位小数
- 符合 Polymarket API 的要求,解决 'invalid amounts' 错误
2026-01-29 03:45:17 +08:00
WrBug c56e08e681 docs: 添加 v2.0.1 release notes 2026-01-28 15:30:20 +08:00
WrBug 7e87965418 fix: 修复订单状态检查中缓存清除导致计时重置的问题
- 修复订单详情为 null 时缓存清除时机问题,避免计时被重置导致订单无法删除
- 统一部分卖出和未成交订单的删除逻辑,都使用60秒重试窗口(之前部分卖出订单等待1小时)
- 删除未使用的常量 PARTIAL_SOLD_CLEANUP_WINDOW_MS
- 优化日志输出,区分部分卖出和未成交订单的日志信息

修复问题:
- 订单超过60秒不会删除的问题
- 部分卖出订单等待时间过长的问题
2026-01-28 15:28:36 +08:00
WrBug e115d459f7 refactor: 优化过滤原因文案的数值显示格式
优化内容:
1. 新增 DateUtils.formatDuration() 函数:将毫秒转换为可读格式(X天X小时X分钟)
2. 优化市场截止时间过滤原因:使用格式化后的时间显示,避免显示过长的毫秒数
3. 优化所有过滤原因中的 BigDecimal 数值显示:
   - 价格区间检查:去除尾随零
   - 价差检查:去除尾随零
   - 订单深度检查:去除尾随零
   - 仓位金额检查:去除尾随零

优化效果:
- 时间显示:1009163461ms (280小时) -> 11天16小时26分钟
- 数值显示:0.50000000000000000000 -> 0.5

影响范围:
- DateUtils.kt
- CopyTradingFilterService.kt
2026-01-28 15:16:12 +08:00
WrBug dd39e59304 fix: 修复禁用RPC节点后仍被使用的问题
问题描述:
- 用户在前端禁用节点后,系统仍然使用禁用的节点
- 原因:RetrofitFactory 的 rpcApiCache 未被清理,导致禁用的节点 URL 继续在拦截器中被使用

修复内容:
1. updateNode 方法:添加禁用节点时清理缓存的逻辑
2. deleteNode 方法:添加删除节点时清理缓存的逻辑
3. getAvailableNode 方法:优化逻辑,确保始终返回可用节点
   - 没有启用节点时返回默认节点
   - 所有节点不可用时返回默认节点
   - 异常时返回默认节点作为兜底
4. 新增 createDefaultNodeConfig 辅助方法:创建默认节点配置
5. 简化 getHttpUrl 和 getWsUrl 方法:移除冗余逻辑

影响范围:
- RpcNodeService.kt
2026-01-28 15:13:07 +08:00
WrBug 2efc04a3c3 fix: 补充缺失的多语言key
修复内容:
1. copyTradingOrders 部分:添加 allFullyMatched 和 partiallyMatched(zh-CN、zh-TW)
2. filteredOrdersList 部分:添加 noFilteredOrders(所有语言文件)

影响范围:
- frontend/src/locales/en/common.json
- frontend/src/locales/zh-CN/common.json
- frontend/src/locales/zh-TW/common.json
2026-01-28 15:11:19 +08:00
WrBug 2b20f4b2e2 fix: 修复订单金额计算和价格范围验证问题
修复了两个关键问题:

1. 订单金额精度错误
   - 原因:总是强制将金额舍入到 4 位小数,导致精度丢失
   - 问题案例:25.09488 被错误地舍入为 25.0948
   - 修复:只在小数位数超过 roundConfig.amount 时才进行舍入,采用与 clob-client 一致的条件舍入逻辑

2. 价格超出范围错误
   - 原因:价格 0.999 舍入到 2 位小数后变成 1.00,违反了 price < 1 的限制
   - 修复:在舍入后验证价格范围,超出时自动调整到最接近的有效值 (0.01-0.99)

主要变更:
- 对价格进行 roundNormal 舍入(参考 clob-client)
- 实现条件舍入逻辑:先 roundUp(amount + 4),必要时再 roundDown(amount)
- 添加价格范围验证 (MIN_PRICE=0.01, MAX_PRICE=0.99)
- 添加 decimalPlaces() 辅助函数
- 修改 roundNormal/roundDown/roundUp 函数,只有必要时才舍入

参考:clob-client/src/order-builder/helpers.ts 的 getOrderRawAmounts 函数
2026-01-23 12:54:48 +08:00
WrBug 419c68c024 fix: 修复本地 Docker 构建时 frontend/dist 和 backend/build 不存在的问题
- 移除 Dockerfile 中无效的 COPY frontend/dist 指令
- 改进前端构建逻辑,添加外部产物检查
- 改进后端构建逻辑,支持复制外部构建的 JAR 文件
- 在 deploy.sh 中添加创建占位符目录步骤,避免 COPY 失败
- 保持与 GitHub Actions 构建流程的兼容性(BUILD_IN_DOCKER=false)

修复了本地使用 ./deploy.sh 部署时的构建错误:
"failed to calculate checksum: /frontend/dist: not found"
2026-01-21 21:04:25 +08:00
WrBug 8889803dd7 fix: 修复 Dockerfile 中前端产物复制逻辑
- 修复 BUILD_IN_DOCKER=true 时 frontend/dist 不存在的问题
- 当 BUILD_IN_DOCKER=true 时,RUN 已经创建了 dist 目录
- COPY frontend/dist ./dist 现在应该能正常工作
2026-01-21 20:56:57 +08:00
WrBug 9c5517768f Merge pull request #24 from WrBug/dynamic_load
feat: 合并 v2.0.0 系统动态更新功能到 main 分支
2026-01-21 20:46:49 +08:00
WrBug 4aa85a9c2d fix: 修复系统更新 API 路由和改进健康检查逻辑
- 修复 /api/update/execute 404 错误,改为 /api/update/update
- 增加健康检查等待时间从30秒到90秒
- 后端日志输出到文件,便于诊断问题
- 每5秒检查一次后端进程状态
- 改进错误诊断信息,区分进程退出和健康检查失败
- 记录后端启动日志的最后100行用于问题排查
2026-01-21 11:53:30 +08:00
WrBug a77b3b10ee feat: 改进系统更新健康检查逻辑
- 增加等待时间从30秒到90秒
- 后端日志输出到文件,便于诊断问题
- 每5秒检查一次后端进程状态
- 改进错误诊断信息,区分进程退出和健康检查失败
- 记录后端启动日志的最后100行用于问题排查
2026-01-21 11:31:19 +08:00
WrBug fabfe601c6 Merge pull request #23 from WrBug/dev
Release v1.1.16: 订单列表优化和多语言修复
2026-01-20 23:32:31 +08:00
76 changed files with 10278 additions and 1221 deletions
+119 -21
View File
@@ -4,6 +4,24 @@ on:
release:
types:
- published # 当通过 GitHub Releases 页面创建 release 时触发
workflow_dispatch:
inputs:
build_type:
description: '构建类型'
required: true
type: choice
options:
- package-only # 只打包产物
- package-and-docker # 打包产物 + Docker 镜像
default: 'package-and-docker'
version:
description: '版本号(例如: v1.0.0'
required: false
type: string
tag_name:
description: 'Git Tag 名称(留空则使用 version'
required: false
type: string
jobs:
build-and-push:
@@ -16,25 +34,61 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }} # 使用 release 对应的 tag
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
- name: Determine build type
id: build_config
run: |
# 确定构建类型
if [ "${{ github.event_name }}" = "release" ]; then
# Release 事件:默认只打包产物(不构建 Docker)
BUILD_TYPE="package-only"
echo "📦 Release 事件:将只打包产物(不构建 Docker)"
else
# workflow_dispatch 事件:使用用户输入
BUILD_TYPE="${{ github.event.inputs.build_type }}"
echo "🔧 手动触发:构建类型 = ${BUILD_TYPE}"
fi
echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_OUTPUT
- name: Extract version and check if pre-release
id: extract_version
run: |
# 从 release tag 中提取版本号(例如 v1.0.0 -> 1.0.0
TAG_NAME="${{ github.event.release.tag_name }}"
if [ -z "$TAG_NAME" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
# 从不同事件源提取版本号
if [ "${{ github.event_name }}" = "release" ]; then
# Release 事件:从 release tag 中提取
TAG_NAME="${{ github.event.release.tag_name }}"
IS_PRERELEASE="${{ github.event.release.prerelease }}"
else
# workflow_dispatch 事件:从输入参数中提取
TAG_NAME="${{ github.event.inputs.tag_name }}"
if [ -z "$TAG_NAME" ]; then
TAG_NAME="${{ github.event.inputs.version }}"
fi
# 如果仍然为空,尝试从 git ref 中提取
if [ -z "$TAG_NAME" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
# 不是 tag,尝试从分支名或 commit SHA 获取
TAG_NAME=${GITHUB_REF#refs/heads/}
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
TAG_NAME="dev-$(date +%Y%m%d-%H%M%S)"
echo "⚠️ 未指定版本号,使用临时版本: $TAG_NAME"
fi
fi
fi
IS_PRERELEASE="false"
fi
# 验证版本号格式:v数字.数字.数字[-后缀](例如 v1.0.0, v2.10.102, v1.0.0-beta
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "错误: 版本号格式不正确,应为 v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
exit 1
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] && [[ ! "$TAG_NAME" =~ ^dev- ]]; then
echo "⚠️ 警告: 版本号格式不符合标准,但仍将继续构建"
echo " 当前版本号: $TAG_NAME"
echo " 标准格式应为: v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
fi
VERSION=${TAG_NAME#v} # 移除 v 前缀
IS_PRERELEASE="${{ github.event.release.prerelease }}"
VERSION=${TAG_NAME#v} # 移除 v 前缀(如果存在)
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
@@ -47,7 +101,7 @@ jobs:
fi
- name: Send Telegram notification (build started)
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
@@ -59,12 +113,15 @@ jobs:
fi
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_URL="${{ github.event.release.html_url }}"
# 构建消息内容(仅包含关键信息)
MESSAGE="🔨 <b>Docker 镜像构建中</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
MESSAGE="🔨 <b>Release 构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
MESSAGE="🔨 <b>构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"
fi
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
@@ -141,12 +198,18 @@ jobs:
echo "✅ 前端文件已复制"
# 创建版本信息文件
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_NOTES=$(echo '${{ github.event.release.body }}' | jq -Rs .)
else
RELEASE_NOTES="\"手动构建 - workflow_dispatch\""
fi
cat > update-package/version.json <<EOF
{
"version": "${{ steps.extract_version.outputs.VERSION }}",
"tag": "${{ steps.extract_version.outputs.TAG }}",
"buildTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"releaseNotes": $(echo '${{ github.event.release.body }}' | jq -Rs .)
"releaseNotes": ${RELEASE_NOTES}
}
EOF
echo "✅ 版本信息已创建"
@@ -169,6 +232,7 @@ jobs:
echo "$CHECKSUM $FILE" > checksums.txt
- name: Upload Update Package to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -178,7 +242,8 @@ jobs:
asset_name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
asset_content_type: application/gzip
- name: Upload Checksums
- name: Upload Checksums to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -188,19 +253,32 @@ jobs:
asset_name: checksums.txt
asset_content_type: text/plain
- name: Upload Update Package as Artifact
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update
path: |
polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
checksums.txt
retention-days: 30
- name: Set up Docker Buildx
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/setup-buildx-action@v3
with:
# 启用多架构构建支持
platforms: linux/amd64,linux/arm64
- name: Log in to Docker Hub
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Prepare Docker build context
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
run: |
echo "📦 准备 Docker 构建上下文..."
# 确保构建产物存在且可访问
@@ -217,6 +295,7 @@ jobs:
ls -lh backend/build/libs/*.jar
- name: Build and push Docker image
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/build-push-action@v5
with:
context: .
@@ -234,6 +313,12 @@ jobs:
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
cache-from: type=registry,ref=wrbug/polyhermes:latest
cache-to: type=inline
- name: Skip Docker build notice
if: steps.build_config.outputs.BUILD_TYPE == 'package-only'
run: |
echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only"
echo "✅ 仅打包产物已完成"
- name: Send Telegram notification
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
@@ -250,13 +335,26 @@ jobs:
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_NAME="${{ github.event.release.name }}"
RELEASE_URL="${{ github.event.release.html_url }}"
REPO_NAME="${{ github.repository }}"
BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}"
# 构建消息内容(仅包含关键信息)
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
MESSAGE="✅ <b>Docker 镜像构建成功</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>Release 构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>Release 打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
fi
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-103
View File
@@ -1,103 +0,0 @@
name: Telegram Notification on PR Merge
on:
pull_request:
types:
- closed # 当 PR 被关闭(合并或关闭)时触发
jobs:
notify:
runs-on: ubuntu-latest
# 只在 PR 被合并到 main 分支时执行
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get PR details
id: pr_details
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
REPO="${{ github.repository }}"
# 获取 PR 详细信息
PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}")
# 获取 PR 变更的文件列表
FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files")
# 提取 PR 描述(body),保留换行,限制长度
PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""')
if [ ${#PR_BODY} -gt 500 ]; then
PR_BODY="${PR_BODY:0:500}..."
fi
# 保存到输出变量(使用 base64 编码避免特殊字符问题)
echo "pr_body<<EOF" >> $GITHUB_OUTPUT
echo "$PR_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Send Telegram notification
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查 PR 是否被合并(而不是仅关闭)
PR_MERGED="${{ github.event.pull_request.merged }}"
if [ "$PR_MERGED" != "true" ]; then
echo "ℹ️ PR 仅关闭,未合并,跳过通知"
exit 0
fi
# 检查必要的环境变量
# 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取 PR 基本信息
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_TITLE="${{ github.event.pull_request.title }}"
PR_URL="${{ github.event.pull_request.html_url }}"
PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
# 获取 PR 详细信息
PR_BODY="${{ steps.pr_details.outputs.pr_body }}"
# 转义 PR 标题中的 HTML 特殊字符
PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&amp;/g' | sed 's/</\&lt;/g' | sed 's/>/\&gt;/g')
# 构建消息内容(仅包含关键信息)
MESSAGE="🚀 <b>main 分支代码更新</b>"$'\n'$'\n'"📝 <b>PR #${PR_NUMBER}:</b> ${PR_TITLE_ESCAPED}"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
exit 1
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
exit 1
fi
+32 -13
View File
@@ -31,21 +31,22 @@ RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
COPY frontend/ ./
# 条件:仅在 Docker 内部编译时执行构建
# 如果 BUILD_IN_DOCKER=false,需要构建上下文复制外部编译的 dist
# 如果 BUILD_IN_DOCKER=false,需要确保构建上下文中存在 frontend/dist
# 注意:COPY frontend/ ./ 已经复制了整个 frontend 目录(包括 dist,如果存在)
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
echo "🔨 Docker 内部编译前端..."; \
npm run build; \
else \
echo "⏭️ 使用外部产物,将在下一步复制"; \
mkdir -p dist; \
echo "⏭️ 使用外部产物..."; \
if [ ! -d "dist" ] || [ -z "$(ls -A dist 2>/dev/null)" ]; then \
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 frontend/dist"; \
echo " 请先执行: cd frontend && npm install && npm run build"; \
exit 1; \
else \
echo "✅ 找到外部构建的前端产物"; \
fi; \
fi
# 如果使用外部产物,从构建上下文复制外部编译的 dist
# 注意:这个 COPY 在 BUILD_IN_DOCKER=false 时必需
# 在 BUILD_IN_DOCKER=true 时,如果前端已编译,这个 COPY 会尝试覆盖,但结果相同
# 如果本地没有 distBUILD_IN_DOCKER=true 且未编译),这个 COPY 会失败,但上面的 RUN 已经编译了
COPY frontend/dist ./dist
# ==================== 阶段2:构建后端 ====================
FROM gradle:8.5-jdk17 AS backend-build
ARG BUILD_IN_DOCKER
@@ -64,9 +65,25 @@ RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
# 复制源代码
COPY backend/src ./src
# 如果使用外部产物,先从构建上下文复制外部编译的 JAR
# 注意:如果 BUILD_IN_DOCKER=true 且本地没有 JAR,这个 COPY 会失败,但会在下面编译生成
COPY backend/build/libs/*.jar build/libs/
# 尝试复制外部构建的 JAR(如果存在)
# 注意:COPY 指令如果源不存在会失败
# GitHub Actions 使用 BUILD_IN_DOCKER=false,会先构建产物,所以 backend/build 应该存在
# 本地开发使用 BUILD_IN_DOCKER=true,会在 Docker 内编译,所以 backend/build 可能不存在
# 解决方案:先复制整个 backend 目录(包括 build,如果存在),然后只使用需要的部分
# 使用 .dockerignore 确保不会复制不需要的文件(如 .gradle、out、bin 等)
COPY backend/build ./build-external
# 处理外部构建的 JAR(如果存在)
RUN if [ -d "build-external/libs" ] && [ -n "$(ls -A build-external/libs/*.jar 2>/dev/null)" ]; then \
echo "📦 找到外部构建的后端产物,复制到 build/libs..."; \
mkdir -p build/libs; \
cp build-external/libs/*.jar build/libs/; \
rm -rf build-external; \
else \
echo "⏭️ 未找到外部构建的 JAR,将在 Docker 内编译"; \
rm -rf build-external; \
mkdir -p build/libs; \
fi
# 条件:仅在 Docker 内部编译时执行构建(会覆盖外部产物)
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
@@ -74,10 +91,12 @@ RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
gradle bootJar --no-daemon; \
else \
echo "⏭️ 使用外部产物"; \
mkdir -p build/libs; \
if [ -z "$(ls -A build/libs/*.jar 2>/dev/null)" ]; then \
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 backend/build/libs/*.jar"; \
echo " 请先执行: cd backend && ./gradlew bootJar"; \
exit 1; \
else \
echo "✅ 使用外部构建的后端产物"; \
fi; \
fi
+1
View File
@@ -74,3 +74,4 @@
- b658270 - 添加订单详情查询脚本
- 07b4d65 - 清理 MarketPollingService 调试日志
+177
View File
@@ -0,0 +1,177 @@
# 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
@@ -0,0 +1,173 @@
# 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
@@ -0,0 +1,144 @@
# 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
@@ -0,0 +1,241 @@
# 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 "✅ 清理完成!现在可以重启应用了"
@@ -0,0 +1,225 @@
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))
}
}
}
@@ -140,7 +140,7 @@ class LeaderController(
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
val result = leaderService.getLeaderDetail(request.leaderId)
result.fold(
onSuccess = { leader ->
@@ -159,6 +159,36 @@ class LeaderController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询被跟单者余额
*/
@PostMapping("/balance")
fun getLeaderBalance(@RequestBody request: LeaderBalanceRequest): ResponseEntity<ApiResponse<LeaderBalanceResponse>> {
return try {
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
val result = leaderService.getLeaderBalance(request.leaderId)
result.fold(
onSuccess = { balance ->
ResponseEntity.ok(ApiResponse.success(balance))
},
onFailure = { e ->
logger.error("查询 Leader 余额失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
)
} catch (e: Exception) {
logger.error("查询 Leader 余额异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
/**
@@ -93,6 +93,16 @@ data class AccountListResponse(
val total: Long
)
/**
* 钱包余额响应(通用类,用于 Account 和 Leader
*/
data class WalletBalanceResponse(
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
/**
* 账户余额响应
*/
@@ -108,6 +118,7 @@ data class AccountBalanceResponse(
*/
data class PositionDto(
val marketId: String,
val title: String?, // 市场名称
val side: String, // YES 或 NO
val quantity: String,
val avgPrice: String,
@@ -0,0 +1,197 @@
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 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 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>?
)
/**
* 回测统计信息 DTO
*/
data class BacktestStatisticsDto(
val totalTrades: Int, // 总交易笔数
val buyTrades: Int, // 买入笔数
val sellTrades: Int, // 卖出笔数
val winTrades: Int, // 盈利交易笔数
val lossTrades: Int, // 亏损交易笔数
val winRate: String, // 胜率(%)
val maxProfit: String, // 最大单笔盈利
val maxLoss: String, // 最大单笔亏损
val maxDrawdown: String, // 最大回撤
val avgHoldingTime: Long? // 平均持仓时间(毫秒)
)
/**
* 回测交易记录 DTO
*/
data class BacktestTradeDto(
val id: Long,
val tradeTime: Long,
val marketId: String,
val marketTitle: String?,
val side: String, // BUY/SELL/SETTLEMENT
val outcome: String,
val outcomeIndex: Int?,
val quantity: String,
val price: String,
val amount: String,
val fee: String,
val profitLoss: String?,
val balanceAfter: String,
val leaderTradeId: String?
)
@@ -36,7 +36,6 @@ data class CopyTradingCreateRequest(
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -75,7 +74,6 @@ data class CopyTradingUpdateRequest(
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -151,7 +149,6 @@ data class CopyTradingDto(
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -36,6 +36,13 @@ data class LeaderListRequest(
val category: String? = null // sports 或 crypto
)
/**
* Leader 余额请求
*/
data class LeaderBalanceRequest(
val leaderId: Long // LeaderID(必需)
)
/**
* Leader 信息响应
*/
@@ -61,3 +68,16 @@ data class LeaderListResponse(
val total: Long
)
/**
* Leader 余额响应
*/
data class LeaderBalanceResponse(
val leaderId: Long,
val leaderAddress: String,
val leaderName: String?,
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
@@ -0,0 +1,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()
}
}
@@ -0,0 +1,142 @@
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 = "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()
)
@@ -83,9 +83,6 @@ data class CopyTrading(
@Column(name = "max_position_value", precision = 20, scale = 8)
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
@Column(name = "max_position_count")
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
@@ -231,7 +231,24 @@ 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"),
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");
companion object {
/**
@@ -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)
}
@@ -278,7 +278,7 @@ class AccountService(
if (accountId == null) {
return Result.failure(IllegalArgumentException("账户ID不能为空"))
}
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
@@ -288,68 +288,19 @@ class AccountService(
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
}
// 查询 USDC 余额和持仓信息
// 使用通用方法查询余额
val balanceResult = runBlocking {
try {
// 查询持仓信息(用于返回持仓列表)
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
val positionsResult = blockchainService.getPositions(account.proxyAddress)
val positions = if (positionsResult.isSuccess) {
positionsResult.getOrNull()?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 使用 /value 接口获取仓位总价值(而不是累加)
val positionBalanceResult = blockchainService.getTotalValue(account.proxyAddress)
val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
}
// 查询可用余额(通过 RPC 查询 USDC 余额)
// 必须使用代理地址查询
val availableBalanceResult = blockchainService.getUsdcBalance(
walletAddress = account.walletAddress,
proxyAddress = account.proxyAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
AccountBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(),
positions = positions
)
} catch (e: Exception) {
logger.error("查询余额失败: ${e.message}", e)
throw e
}
blockchainService.getWalletBalance(account.proxyAddress)
}
Result.success(balanceResult)
balanceResult.map { walletBalance: WalletBalanceResponse ->
AccountBalanceResponse(
availableBalance = walletBalance.availableBalance,
positionBalance = walletBalance.positionBalance,
totalBalance = walletBalance.totalBalance,
positions = walletBalance.positions
)
}
} catch (e: Exception) {
logger.error("查询账户余额失败", e)
Result.failure(e)
@@ -0,0 +1,131 @@
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
/**
* 回测数据服务
* 直接从 Polymarket Data API 获取 Leader 历史交易
*/
@Service
class BacktestDataService(
private val leaderRepository: LeaderRepository,
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(BacktestDataService::class.java)
/**
* 分页获取 Leader 历史交易(用于回测恢复)
* 支持重试机制:最多重试5次,每次间隔1秒
*
* @param leaderId Leader ID
* @param startTime 开始时间(毫秒时间戳)
* @param endTime 结束时间(毫秒时间戳)
* @param page 页码 (从 0 开始)
* @param size 每页数量
* @return 历史交易列表
* @throws Exception 重试5次后仍然失败时抛出异常
*/
suspend fun getLeaderHistoricalTradesForPage(
leaderId: Long,
startTime: Long,
endTime: Long,
page: Int,
size: Int
): List<TradeData> {
logger.info("分页获取 Leader 历史交易: leaderId=$leaderId, timeRange=$startTime - $endTime, page=$page, size=$size")
// 1. 验证 Leader 是否存在
val leader = leaderRepository.findById(leaderId).orElse(null)
?: throw IllegalArgumentException("Leader 不存在: $leaderId")
val dataApi = retrofitFactory.createDataApi()
val offset = page * size
val maxRetries = 5
val retryDelay = 1000L // 1秒
// 2. 重试机制:最多重试5次
var lastException: Exception? = null
for (attempt in 1..maxRetries) {
try {
val response = dataApi.getUserActivity(
user = leader.leaderAddress,
type = listOf("TRADE"),
start = startTime / 1000,
end = endTime / 1000,
limit = size,
offset = offset,
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("成功获取第 $page 页数据,共 ${activities.size} 条交易(第 $attempt 次尝试)")
return 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, range=$startTime - $endTime")
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
}
}
} catch (e: Exception) {
lastException = e
logger.warn("$attempt/$maxRetries 次尝试获取第 $page 页数据失败: ${e.message}")
// 如果不是最后一次尝试,则等待后重试
if (attempt < maxRetries) {
logger.info("等待 $retryDelay 毫秒后重试...")
delay(retryDelay)
}
}
}
// 重试5次后仍然失败,抛出异常
val errorMsg = "重试 $maxRetries 次后仍然失败获取第 $page 页数据"
logger.error(errorMsg, lastException)
throw Exception(errorMsg, lastException)
}
}
@@ -0,0 +1,715 @@
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.copytrading.configs.CopyTradingFilterService
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
@Service
class BacktestExecutionService(
private val backtestTaskRepository: BacktestTaskRepository,
private val backtestTradeRepository: BacktestTradeRepository,
private val backtestDataService: BacktestDataService,
private val marketPriceService: MarketPriceService,
private val copyTradingFilterService: CopyTradingFilterService
) {
private val logger = LoggerFactory.getLogger(BacktestExecutionService::class.java)
/**
* 持仓数据结构
*/
data class Position(
val marketId: String,
val outcome: String,
val outcomeIndex: Int?,
var quantity: BigDecimal,
val avgPrice: BigDecimal,
val leaderBuyQuantity: BigDecimal?
)
/**
* 将回测任务转换为虚拟的 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, // 回测无实时价差数据
keywordFilterMode = task.keywordFilterMode,
keywords = task.keywords,
configName = null,
pushFailedOrders = false,
pushFilteredOrders = false,
createdAt = task.createdAt,
updatedAt = task.updatedAt
)
}
/**
* 执行回测任务(支持分页和恢复)
* 自动处理所有页面的数据,支持中断恢复
*/
@Transactional
suspend fun executeBacktest(task: BacktestTask, page: Int = 1, size: Int = 100) {
try {
logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}, startPage=$page, pageSize=$size")
// 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>()
// 每日订单数缓存:key为日期字符串(yyyy-MM-dd)value为当天的 BUY 订单数
val dailyOrderCountCache = mutableMapOf<String, Int>()
// 每日亏损缓存:key为日期字符串(yyyy-MM-dd)value为当天的累计亏损金额
val dailyLossCache = mutableMapOf<String, BigDecimal>()
// 3. 计算回测时间范围
val endTime = System.currentTimeMillis()
val startTime = task.startTime
logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)}, " +
"初始余额: ${task.initialBalance.toPlainString()}")
// 4. 恢复机制:如果有恢复点,计算从哪一页开始(页码从 0 开始)
val startPage = if (task.lastProcessedTradeIndex != null) {
val lastProcessedIndex = task.lastProcessedTradeIndex!!
// 计算已处理的页码(从 0 开始)
val processedPage = lastProcessedIndex / size
// 特殊情况:如果lastProcessedTradeIndex刚好是100的倍数减1(比如99,199,299...
// 说明该页已经完全处理,应该从下一页开始
val nextPage = if (lastProcessedIndex % size == size - 1) {
processedPage + 1
} else {
processedPage
}
logger.info("恢复任务:已处理索引=$lastProcessedIndex, 计算页码=$nextPage, size=$size")
nextPage
} else {
logger.info("新任务:从第0页开始")
0
}
// 5. 分页获取和处理交易数据
var currentPage = maxOf(startPage, page)
// 计算下一个要处理的全局索引(用于日志和统计)
val nextGlobalIndex = if (task.lastProcessedTradeIndex != null) {
task.lastProcessedTradeIndex!! + 1
} else {
0
}
logger.info("开始分页处理:起始页=$currentPage, 下一个要处理的索引=$nextGlobalIndex")
while (true) {
// 定期从数据库重新加载任务状态,确保能及时响应停止操作
val currentTaskStatus = backtestTaskRepository.findById(task.id!!).orElse(null)
if (currentTaskStatus == null || currentTaskStatus.status != "RUNNING") {
logger.info("回测任务状态已变更: ${currentTaskStatus?.status},停止执行")
break
}
logger.info("正在获取第 $currentPage 页数据...")
// 每页使用独立的交易列表,避免跨页重复保存
val currentPageTrades = mutableListOf<BacktestTrade>()
try {
// 获取当前页的交易数据(支持重试5次)
val pageTrades = backtestDataService.getLeaderHistoricalTradesForPage(
task.leaderId,
startTime,
endTime,
currentPage,
size
)
if (pageTrades.isEmpty()) {
logger.info("$currentPage 页无数据,所有数据处理完成")
break
}
logger.info("$currentPage 页获取到 ${pageTrades.size} 条交易")
// 处理当前页的交易
var lastProcessedIndexInPage: Int? = null
for (localIndex in pageTrades.indices) {
val leaderTrade = pageTrades[localIndex]
// 计算当前交易在全局数据中的索引(从 0 开始)
val index = currentPage * size + localIndex
// 如果是恢复任务,跳过已处理的条目
if (task.lastProcessedTradeIndex != null && index <= task.lastProcessedTradeIndex!!) {
logger.debug("跳过已处理的交易: index=$index, lastProcessedIndex=${task.lastProcessedTradeIndex}")
continue
}
// 记录当前处理的索引
lastProcessedIndexInPage = index
// 更新进度
val progress = if (pageTrades.size > 0) {
(localIndex * 100) / pageTrades.size
} 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)
// 5.2 检查余额和持仓状态
if (currentBalance < BigDecimal.ZERO) {
logger.info("余额已为负,直接终止回测: $currentBalance")
break
}
if (currentBalance < BigDecimal.ONE && positions.isEmpty()) {
logger.info("余额不足且无持仓,停止回测: $currentBalance")
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 quantity = finalFollowAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN)
val totalCost = finalFollowAmount
// 更新余额和持仓
currentBalance -= totalCost
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
positions[positionKey] = Position(
marketId = leaderTrade.marketId,
outcome = leaderTrade.outcome ?: "",
outcomeIndex = leaderTrade.outcomeIndex,
quantity = quantity,
avgPrice = leaderTrade.price.toSafeBigDecimal(),
leaderBuyQuantity = leaderTrade.size.toSafeBigDecimal()
)
// 记录交易到当前页列表
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 = finalFollowAmount,
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("保存第 $currentPage 页的交易数据,共 ${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("$currentPage 页处理完成,更新索引: ${task.lastProcessedTradeIndex}, 总处理数: ${task.processedTradeCount}")
}
} else {
logger.info("$currentPage 页没有交易需要保存")
}
// 将当前页交易添加到全局列表(用于最终统计)
trades.addAll(currentPageTrades)
// 准备处理下一页
currentPage++
} catch (e: Exception) {
logger.error("获取或处理第 $currentPage 页数据失败: ${e.message}", e)
// 重试失败,标记任务为 FAILED
throw e
}
}
// 6. 处理回测结束时仍未到期的持仓
currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime)
// 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
}
}
/**
* 结算已到期的市场
*/
private suspend fun settleExpiredPositions(
task: BacktestTask,
positions: MutableMap<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long
): BigDecimal {
var balance = currentBalance
for ((positionKey, position) in positions.toList()) {
try {
// 获取市场当前价格
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
// 记录结算交易
trades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = currentTime,
marketId = position.marketId,
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
))
// 移除已结算的持仓
positions.remove(positionKey)
} catch (e: Exception) {
logger.error("结算市场失败: marketId=${position.marketId}, outcomeIndex=${position.outcomeIndex}", e)
}
}
return balance
}
/**
* 结算未到期持仓
*/
private suspend fun settleRemainingPositions(
task: BacktestTask,
positions: MutableMap<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long
): 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
// 记录平仓交易
trades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = currentTime,
marketId = position.marketId,
marketTitle = "",
side = "SETTLEMENT",
outcome = "CLOSED",
outcomeIndex = position.outcomeIndex,
quantity = quantity,
price = avgPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = balance,
leaderTradeId = null
))
}
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,139 @@
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 {
logger.debug("开始轮询待执行的回测任务")
// 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()) {
logger.debug("没有待执行的回测任务")
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 {
// 支持恢复:如果有恢复点,计算从哪一页开始
val pageSize = 100
val page = if (currentTask.lastProcessedTradeIndex != null) {
// 从第几页开始(页码从 0 开始)
// 例如:已处理了99笔,lastProcessedTradeIndex=99,应从第1页开始(offset=100
val lastProcessedIndex = currentTask.lastProcessedTradeIndex!!
// 计算已处理的页码(从 0 开始)
val processedPage = lastProcessedIndex / pageSize
// 特殊情况:如果lastProcessedTradeIndex刚好是100的倍数减1(比如99,199,299...
// 说明该页已经完全处理,应该从下一页开始
val nextPage = if (lastProcessedIndex % pageSize == pageSize - 1) {
processedPage + 1
} else {
processedPage
}
logger.info("恢复任务:已处理索引=$lastProcessedIndex, 计算页码=$nextPage, size=$pageSize")
nextPage
} else {
logger.info("新任务:从第0页开始")
0 // 从第0页开始(offset=0
}
logger.info("执行回测任务: taskId=${currentTask.id}, page=$page, size=$pageSize")
executionService.executeBacktest(currentTask, page = page, size = pageSize)
}
} 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,373 @@
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
}
)
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()
}
)
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)
}
}
}
/**
* 扩展函数: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
)
}
@@ -8,9 +8,12 @@ import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.PositionResponse
import com.wrbug.polymarketbot.api.ValueResponse
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.dto.PositionDto
import com.wrbug.polymarketbot.dto.WalletBalanceResponse
import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.service.system.RpcNodeService
@@ -255,7 +258,6 @@ class BlockchainService(
return Result.failure(IllegalArgumentException("代理地址不能为空"))
}
// 使用 RPC 查询 USDC 余额(使用代理地址)
val balance = queryUsdcBalanceViaRpc(proxyAddress)
Result.success(balance)
@@ -264,6 +266,82 @@ class BlockchainService(
Result.failure(e)
}
}
/**
* 查询钱包余额(通用方法)
* 用于 Account 和 Leader 的余额查询
* @param walletAddress 钱包地址(代理地址,Polymarket 使用代理地址存储资产)
* @return WalletBalanceResponse 包含可用余额、仓位余额、总余额和持仓列表
*/
suspend fun getWalletBalance(walletAddress: String): Result<WalletBalanceResponse> {
return try {
if (walletAddress.isBlank()) {
logger.error("钱包地址为空,无法查询余额")
return Result.failure(IllegalArgumentException("钱包地址不能为空"))
}
// 1. 查询持仓信息(用于返回持仓列表)
val positionsResult = getPositions(walletAddress)
val positions = if (positionsResult.isSuccess) {
// 过滤掉价值为0的仓位
positionsResult.getOrNull()?.filter { pos ->
val currentValue = pos.currentValue ?: 0.0
currentValue > 0
}?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
title = pos.title,
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 2. 使用 /value 接口获取仓位总价值
val positionBalanceResult = getTotalValue(walletAddress)
val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
}
// 3. 查询可用余额(通过 RPC 查询 USDC 余额)
val availableBalanceResult = getUsdcBalance(
walletAddress = walletAddress,
proxyAddress = walletAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 4. 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
Result.success(
WalletBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(),
positions = positions
)
)
} catch (e: Exception) {
logger.error("查询钱包余额失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 RPC 查询 USDC 余额
@@ -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("已清空已结算市场缓存")
}
}
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.util.lt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.DateUtils
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.accounts.AccountService
@@ -202,12 +203,16 @@ class CopyTradingFilterService(
// 检查最低价格
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
return FilterResult.priceRangeFailed("价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
val minPriceStr = copyTrading.minPrice.stripTrailingZeros().toPlainString()
return FilterResult.priceRangeFailed("价格低于最低限制: $priceStr < $minPriceStr")
}
// 检查最高价格
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
return FilterResult.priceRangeFailed("价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
val maxPriceStr = copyTrading.maxPrice.stripTrailingZeros().toPlainString()
return FilterResult.priceRangeFailed("价格高于最高限制: $priceStr > $maxPriceStr")
}
return FilterResult.passed()
@@ -245,7 +250,9 @@ class CopyTradingFilterService(
val spread = bestAsk.subtract(bestBid)
if (spread.gt(copyTrading.maxSpread)) {
return FilterResult.spreadFailed("价差过大: $spread > ${copyTrading.maxSpread}", orderbook)
val spreadStr = spread.stripTrailingZeros().toPlainString()
val maxSpreadStr = copyTrading.maxSpread.stripTrailingZeros().toPlainString()
return FilterResult.spreadFailed("价差过大: $spreadStr > $maxSpreadStr", orderbook)
}
return FilterResult.passed()
@@ -285,7 +292,9 @@ class CopyTradingFilterService(
val totalDepth = bidsDepth.add(asksDepth)
if (totalDepth.lt(copyTrading.minOrderDepth)) {
return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
val totalDepthStr = totalDepth.stripTrailingZeros().toPlainString()
val minDepthStr = copyTrading.minOrderDepth.stripTrailingZeros().toPlainString()
return FilterResult.orderDepthFailed("订单深度不足: $totalDepthStr < $minDepthStr", orderbook)
}
return FilterResult.passed()
@@ -306,7 +315,7 @@ class CopyTradingFilterService(
outcomeIndex: Int?
): FilterResult {
// 如果未配置仓位限制,直接通过
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
if (copyTrading.maxPositionValue == null) {
return FilterResult.passed()
}
@@ -348,41 +357,14 @@ class CopyTradingFilterService(
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
val currentValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
val dbValueStr = dbValue.stripTrailingZeros().toPlainString()
val extValueStr = extValue.stripTrailingZeros().toPlainString()
val orderAmountStr = copyOrderAmount.stripTrailingZeros().toPlainString()
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
val maxValueStr = copyTrading.maxPositionValue.stripTrailingZeros().toPlainString()
return FilterResult.maxPositionValueFailed(
"超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
)
}
}
// 检查最大仓位数量(如果配置了)
if (copyTrading.maxPositionCount != null) {
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
val extCount = positions.currentPositions
.filter { it.accountId == copyTrading.accountId }
.map { it.marketId }
.distinct()
.size
val currentPositionCount = maxOf(dbCount, extCount)
// 检查:如果当前没有该市场该方向的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
// 判断当前市场该方向是否已有活跃仓位(数据库)
val hasDbPosition = if (outcomeIndex != null) {
copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id, marketId, outcomeIndex
).isNotEmpty()
} else {
false
}
val hasExtPosition = marketPositions.isNotEmpty()
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
return FilterResult.maxPositionCountFailed(
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
"超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentValueStr} USDC (DB=${dbValueStr}, Ext=${extValueStr}), 跟单金额=${orderAmountStr} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC"
)
}
}
@@ -420,8 +402,10 @@ class CopyTradingFilterService(
val remainingTime = marketEndDate - currentTime
if (remainingTime > copyTrading.maxMarketEndDate) {
val remainingTimeFormatted = DateUtils.formatDuration(remainingTime)
val maxLimitFormatted = DateUtils.formatDuration(copyTrading.maxMarketEndDate)
return FilterResult.marketEndDateFailed(
"市场截止时间超出限制: 剩余时间=${remainingTime}ms (${remainingTime / (1000 * 60 * 60)}小时) > 最大限制=${copyTrading.maxMarketEndDate}ms (${copyTrading.maxMarketEndDate / (1000 * 60 * 60)}小时)"
"市场截止时间超出限制: 剩余时间=${remainingTimeFormatted} > 最大限制=${maxLimitFormatted}"
)
}
@@ -100,7 +100,6 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate,
@@ -132,7 +131,6 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal(),
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate,
@@ -164,7 +162,6 @@ class CopyTradingService(
minPrice = config.minPrice,
maxPrice = config.maxPrice,
maxPositionValue = config.maxPositionValue,
maxPositionCount = config.maxPositionCount,
keywordFilterMode = config.keywordFilterMode,
keywords = config.keywords,
configName = configName,
@@ -282,16 +279,6 @@ class CopyTradingService(
} else {
copyTrading.maxPositionValue
},
// 处理 maxPositionCount-1 表示要清空(设置为 null),null 表示不更新
maxPositionCount = if (request.maxPositionCount != null) {
if (request.maxPositionCount == -1) {
null
} else {
request.maxPositionCount
}
} else {
copyTrading.maxPositionCount
},
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
keywords = if (request.keywords != null) {
convertKeywordsToJson(request.keywords)
@@ -520,7 +507,6 @@ class CopyTradingService(
minPrice = copyTrading.minPrice?.toPlainString(),
maxPrice = copyTrading.maxPrice?.toPlainString(),
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
maxPositionCount = copyTrading.maxPositionCount,
keywordFilterMode = copyTrading.keywordFilterMode,
keywords = convertJsonToKeywords(copyTrading.keywords),
configName = copyTrading.configName,
@@ -585,7 +571,6 @@ class CopyTradingService(
val minPrice: BigDecimal?,
val maxPrice: BigDecimal?,
val maxPositionValue: BigDecimal?,
val maxPositionCount: Int?,
val keywordFilterMode: String,
val keywords: String?, // JSON 字符串
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
@@ -20,8 +20,6 @@ enum class FilterStatus {
FAILED_ORDER_DEPTH,
/** 失败:超过最大仓位金额 */
FAILED_MAX_POSITION_VALUE,
/** 失败:超过最大仓位数量 */
FAILED_MAX_POSITION_COUNT,
/** 失败:关键字过滤 */
FAILED_KEYWORD_FILTER,
/** 失败:市场截止时间超出限制 */
@@ -87,12 +85,6 @@ data class FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_VALUE,
reason = reason
)
/** 超过最大仓位数量 */
fun maxPositionCountFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
reason = reason
)
/** 关键字过滤失败 */
fun keywordFilterFailed(reason: String) = FilterResult(
@@ -5,10 +5,12 @@ import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.util.CategoryValidator
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import kotlinx.coroutines.runBlocking
/**
* Leader 管理服务
@@ -17,7 +19,8 @@ import org.springframework.transaction.annotation.Transactional
class LeaderService(
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val copyTradingRepository: CopyTradingRepository
private val copyTradingRepository: CopyTradingRepository,
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
@@ -176,7 +179,7 @@ class LeaderService(
return try {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId)
Result.success(toDto(leader, copyTradingCount))
} catch (e: Exception) {
@@ -184,6 +187,40 @@ class LeaderService(
Result.failure(e)
}
}
/**
* 查询 Leader 余额
* 使用代理地址查询 USDC 余额和持仓信息
*/
fun getLeaderBalance(leaderId: Long): Result<LeaderBalanceResponse> {
return try {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// Leader 的 leaderAddress 就是代理地址
val walletAddress = leader.leaderAddress
// 使用通用方法查询余额
val balanceResult = runBlocking {
blockchainService.getWalletBalance(walletAddress)
}
balanceResult.map { walletBalance: WalletBalanceResponse ->
LeaderBalanceResponse(
leaderId = leader.id!!,
leaderAddress = leader.leaderAddress,
leaderName = leader.leaderName,
availableBalance = walletBalance.availableBalance,
positionBalance = walletBalance.positionBalance,
totalBalance = walletBalance.totalBalance,
positions = walletBalance.positions
)
}
} catch (e: Exception) {
logger.error("查询 Leader 余额失败", e)
Result.failure(e)
}
}
/**
* 转换为 DTO
@@ -36,13 +36,11 @@ class OrderSigningService {
size = 2,
amount = 4
)
// 金额精度限制(根据 Polymarket API 要求)
// makerAmount (USDC) 最多 2 位小数
// takerAmount (shares) 最多 4 位小数
private val MAKER_AMOUNT_DECIMALS = 2 // USDC 金额精度
private val TAKER_AMOUNT_DECIMALS = 4 // shares 数量精度
// 价格有效范围(Polymarket API 要求)
private val MIN_PRICE = BigDecimal("0.01")
private val MAX_PRICE = BigDecimal("0.99")
/**
* 订单金额计算结果
*/
@@ -62,7 +60,9 @@ class OrderSigningService {
/**
* 计算订单金额(makerAmount 和 takerAmount
*
*
* 参考 clob-client/src/order-builder/helpers.ts 的 getOrderRawAmounts 函数
*
* @param side BUY 或 SELL
* @param size 数量(shares
* @param price 价格(0-1 之间)
@@ -77,49 +77,59 @@ class OrderSigningService {
): OrderAmounts {
val sizeDecimal = size.toSafeBigDecimal()
val priceDecimal = price.toSafeBigDecimal()
// 对价格进行 roundNormal 处理(与 clob-client 保持一致)
var rawPrice = roundNormal(priceDecimal, roundConfig.price)
// 验证价格范围,如果超出则调整到最接近的有效值
// Polymarket API 要求: 0.01 <= price <= 0.99
if (rawPrice > MAX_PRICE) {
logger.warn("价格超出最大限制,已调整: $priceDecimal -> $MAX_PRICE")
rawPrice = MAX_PRICE
} else if (rawPrice < MIN_PRICE) {
logger.warn("价格低于最小限制,已调整: $priceDecimal -> $MIN_PRICE")
rawPrice = MIN_PRICE
}
if (side.uppercase() == "BUY") {
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
// makerAmount 是 USDC 金额,最多 2 位小数
// takerAmount 是 shares 数量,最多 4 位小数
val rawTakerAmt = roundDown(sizeDecimal, roundConfig.size)
// makerAmount = price * size,使用原始价格计算(与SDK保持一致)
// 先使用原始价格计算,然后再进行舍入,确保精度一致
var rawMakerAmt = rawTakerAmt.multiply(priceDecimal)
// 确保 makerAmount 精度(USDC,最多 2 位小数)
rawMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
// 确保 takerAmount 精度(shares,最多 4 位小数)
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
// 参考 clob-client/src/order-builder/helpers.ts 第 73-89 行
// 注意:Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
// takerAmount (shares) 使用 4 位小数
val rawTakerAmt = roundDown(sizeDecimal, 4)
var rawMakerAmt = rawTakerAmt.multiply(rawPrice)
// makerAmount (USDC) 使用 2 位小数
if (decimalPlaces(rawMakerAmt) > 2) {
rawMakerAmt = roundUp(rawMakerAmt, 2 + 4)
if (decimalPlaces(rawMakerAmt) > 2) {
rawMakerAmt = roundDown(rawMakerAmt, 2)
}
}
// 转换为 wei6 位小数)
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
} else {
// SELL: makerAmount = size (shares), takerAmount = price * size (USDC)
// 根据 Polymarket API 要求:
// - makerAmount (shares) 最多 2 位小数
// - takerAmount (USDC) 最多 4 位小数
// 参考 clob-client/src/order-builder/helpers.ts 第 90-105 行
val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size)
// takerAmount = price * size,使用原始价格计算(不使用舍入后的价格)
// SDK期望使用原始价格进行计算,以保留足够的精度
// 例如:0.9596 * 16.09 = 15.439964,而不是 0.96 * 16.09 = 15.4464
val rawTakerAmt = rawMakerAmt.multiply(priceDecimal)
// 确保 makerAmount 精度(shares,最多 2 位小数,符合 API 要求)
val finalMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
// 确保 takerAmount 精度(USDC,最多 4 位小数,符合 API 要求)
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
var rawTakerAmt = rawMakerAmt.multiply(rawPrice)
// 如果 takerAmount 的小数位数超过 roundConfig.amount,进行特殊舍入处理
if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
rawTakerAmt = roundUp(rawTakerAmt, roundConfig.amount + 4)
if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
rawTakerAmt = roundDown(rawTakerAmt, roundConfig.amount)
}
}
// 转换为 wei6 位小数)
val makerAmount = parseUnits(finalMakerAmt, COLLATERAL_TOKEN_DECIMALS)
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
}
}
@@ -324,23 +334,65 @@ class OrderSigningService {
/**
* 正常舍入(四舍五入)
* 参考 clob-client/src/utilities.ts 的 roundNormal 函数
* 只有当小数位数超过 decimals 时才进行舍入
*
* @param value 要舍入的数值
* @param decimals 目标小数位数
* @return 舍入后的数值
*/
private fun roundNormal(value: BigDecimal, decimals: Int): BigDecimal {
if (decimalPlaces(value) <= decimals) {
return value
}
return value.setScale(decimals, RoundingMode.HALF_UP)
}
/**
* 向下舍入
* 参考 clob-client/src/utilities.ts 的 roundDown 函数
* 只有当小数位数超过 decimals 时才进行舍入
*
* @param value 要舍入的数值
* @param decimals 目标小数位数
* @return 舍入后的数值
*/
private fun roundDown(value: BigDecimal, decimals: Int): BigDecimal {
if (decimalPlaces(value) <= decimals) {
return value
}
return value.setScale(decimals, RoundingMode.DOWN)
}
/**
* 向上舍入
* 参考 clob-client/src/utilities.ts 的 roundUp 函数
* 只有当小数位数超过 decimals 时才进行舍入
*
* @param value 要舍入的数值
* @param decimals 目标小数位数
* @return 舍入后的数值
*/
private fun roundUp(value: BigDecimal, decimals: Int): BigDecimal {
if (decimalPlaces(value) <= decimals) {
return value
}
return value.setScale(decimals, RoundingMode.UP)
}
/**
* 计算 BigDecimal 的小数位数
* 参考 clob-client/src/utilities.ts 的 decimalPlaces 函数
*
* @param value 要计算的数值
* @return 小数位数
*/
private fun decimalPlaces(value: BigDecimal): Int {
if (value.scale() <= 0) {
return 0
}
// 去除尾部的零,获取真实的小数位数
return value.stripTrailingZeros().scale()
}
}
@@ -185,6 +185,7 @@ open class CopyOrderTrackingService(
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
if (isUniqueConstraintViolation(e)) {
@@ -1430,7 +1431,6 @@ open class CopyOrderTrackingService(
FilterStatus.FAILED_SPREAD -> "SPREAD"
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
}
@@ -69,9 +69,6 @@ class OrderStatusUpdateService(
// 订单详情为 null 的重试时间窗口(1分钟)
private val ORDER_NULL_RETRY_WINDOW_MS = 60000L
// 订单详情为 null 但已部分卖出的清理时间窗口(1小时)
private val PARTIAL_SOLD_CLEANUP_WINDOW_MS = 3600000L
@EventListener(ApplicationReadyEvent::class)
fun onApplicationReady() {
logger.info("订单状态更新服务已启动,将每5秒轮询一次")
@@ -297,53 +294,38 @@ class OrderStatusUpdateService(
updatedAt = System.currentTimeMillis()
)
copyOrderTrackingRepository.save(updatedOrder)
// 清除缓存(仅在处理完成后清除)
orderNullDetectionTime.remove(order.buyOrderId)
} catch (e: Exception) {
logger.error("更新订单状态失败: orderId=${order.buyOrderId}, error=${e.message}", e)
}
}
// 清除缓存,下次重新检测
orderNullDetectionTime.remove(order.buyOrderId)
// 未超过60秒,继续等待,不清除缓存
continue
}
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
val hasMatchedDetails =
sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
// 检查是否超过清理时间窗口(1小时)
val orderAge = currentTime - order.createdAt
if (orderAge >= PARTIAL_SOLD_CLEANUP_WINDOW_MS) {
logger.warn("订单详情为 null 且已部分卖出,但超过清理时间窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单(超时清理): orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
// 清除缓存
orderNullDetectionTime.remove(order.buyOrderId)
} catch (e: Exception) {
logger.error(
"删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}",
e
)
}
continue
} else {
logger.debug("订单详情为 null 但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
// 清除缓存,下次重新检测
orderNullDetectionTime.remove(order.buyOrderId)
continue
}
}
// 检查是否超过重试时间窗口
// 检查是否超过重试时间窗口(统一使用60秒,无论是否已部分卖出)
if (currentTime - firstDetectionTime < ORDER_NULL_RETRY_WINDOW_MS) {
// 未超过重试窗口,记录日志并等待下次轮询
val elapsedSeconds = ((currentTime - firstDetectionTime) / 1000).toInt()
logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
if (hasPartialSold) {
logger.debug("订单详情为 null 且已部分卖出,等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
} else {
logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
}
continue
}
// 超过重试窗口,删除本地订单
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
// 超过重试窗口,删除本地订单(无论是否已部分卖出)
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
if (hasPartialSold) {
logger.warn("订单详情为 null 且已部分卖出,超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
} else {
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
}
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
@@ -775,52 +757,38 @@ class OrderStatusUpdateService(
updatedAt = System.currentTimeMillis()
)
copyOrderTrackingRepository.save(updatedOrder)
// 清除缓存(仅在处理完成后清除)
orderNullDetectionTime.remove(order.buyOrderId)
} catch (e: Exception) {
logger.error("更新订单状态失败: orderId=${order.buyOrderId}, error=${e.message}", e)
}
}
// 清除缓存,下次重新检测
orderNullDetectionTime.remove(order.buyOrderId)
// 未超过60秒,继续等待,不清除缓存
continue
}
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
// 检查是否超过清理时间窗口(1小时)
val orderAge = currentTime - order.createdAt
if (orderAge >= PARTIAL_SOLD_CLEANUP_WINDOW_MS) {
logger.warn("订单详情为 null 且已部分卖出,但超过清理时间窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单(超时清理): orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
// 清除缓存
orderNullDetectionTime.remove(order.buyOrderId)
} catch (e: Exception) {
logger.error(
"删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}",
e
)
}
continue
} else {
logger.debug("订单详情为 null 但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
// 清除缓存,下次重新检测
orderNullDetectionTime.remove(order.buyOrderId)
continue
}
}
// 检查是否超过重试时间窗口
// 检查是否超过重试时间窗口(统一使用60秒,无论是否已部分卖出)
if (currentTime - firstDetectionTime < ORDER_NULL_RETRY_WINDOW_MS) {
// 未超过重试窗口,记录日志并等待下次轮询
val elapsedSeconds = ((currentTime - firstDetectionTime) / 1000).toInt()
logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
if (hasPartialSold) {
logger.debug("订单详情为 null 且已部分卖出,等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
} else {
logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
}
continue
}
// 超过重试窗口,删除本地订单
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
// 超过重试窗口,删除本地订单(无论是否已部分卖出)
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
if (hasPartialSold) {
logger.warn("订单详情为 null 且已部分卖出,超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
} else {
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
}
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
@@ -98,8 +98,8 @@ class RpcNodeService(
.filterNot { isDefaultNode(it) } // 排除默认节点
if (nodes.isEmpty()) {
logger.warn("没有配置任何 RPC 节点,使用默认节点: $DEFAULT_RPC_URL")
return Result.failure(IllegalStateException("没有配置任何 RPC 节点"))
logger.warn("没有配置任何启用的 RPC 节点,将使用默认节点")
return Result.success(createDefaultNodeConfig())
}
// 优先使用最近检查状态为 HEALTHY 的节点
@@ -136,39 +136,53 @@ class RpcNodeService(
}
}
// 所有节点都不可用,返回失败
logger.warn("所有 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL")
Result.failure(IllegalStateException("所有 RPC 节点都不可用"))
// 所有节点都不可用,返回默认节点
logger.warn("所有启用的 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL")
Result.success(createDefaultNodeConfig())
} catch (e: Exception) {
logger.error("获取可用节点失败: ${e.message}", e)
Result.failure(e)
// 即使失败也返回默认节点,确保系统可用
logger.warn("获取可用节点出现异常,使用默认节点作为兜底")
Result.success(createDefaultNodeConfig())
}
}
/**
* 创建默认节点配置
* 用于兜底,确保系统始终有可用的 RPC 节点
*/
private fun createDefaultNodeConfig(): RpcNodeConfig {
return RpcNodeConfig(
id = 0L,
providerType = RpcProviderType.PUBLIC.name,
name = "默认节点",
httpUrl = DEFAULT_RPC_URL,
wsUrl = DEFAULT_WS_URL,
apiKey = null,
enabled = true,
priority = 9999,
lastCheckTime = System.currentTimeMillis(),
lastCheckStatus = NodeHealthStatus.HEALTHY.name,
responseTimeMs = null,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
}
/**
* 获取节点的 HTTP URL (如果没有配置,使用默认节点)
*/
fun getHttpUrl(): String {
val nodeResult = getAvailableNode()
return if (nodeResult.isSuccess) {
nodeResult.getOrNull()?.httpUrl ?: DEFAULT_RPC_URL
} else {
logger.warn("没有可用的用户配置节点,使用默认节点")
DEFAULT_RPC_URL
}
val node = getAvailableNode().getOrNull()
return node?.httpUrl ?: DEFAULT_RPC_URL
}
/**
* 获取节点的 WebSocket URL (如果没有配置,使用默认节点)
*/
fun getWsUrl(): String {
val nodeResult = getAvailableNode()
return if (nodeResult.isSuccess) {
nodeResult.getOrNull()?.wsUrl ?: DEFAULT_WS_URL
} else {
logger.warn("没有可用的用户配置节点,使用默认 WS 节点")
DEFAULT_WS_URL
}
val node = getAvailableNode().getOrNull()
return node?.wsUrl ?: DEFAULT_WS_URL
}
/**
@@ -263,6 +277,13 @@ class RpcNodeService(
return Result.failure(IllegalArgumentException("默认节点不允许更新"))
}
// 检查是否禁用节点,如果是则清理缓存
val isDisabling = request.enabled == false && node.enabled == true
if (isDisabling) {
logger.info("节点被禁用,清理 RPC 缓存: ${node.httpUrl}")
retrofitFactory.clearRpcApiCache(node.httpUrl)
}
// 更新字段
val updatedNode = node.copy(
name = request.name ?: node.name,
@@ -272,7 +293,7 @@ class RpcNodeService(
)
val savedNode = rpcNodeConfigRepository.save(updatedNode)
logger.info("成功更新 RPC 节点: ${savedNode.name}")
logger.info("成功更新 RPC 节点: ${savedNode.name}, enabled=${savedNode.enabled}")
Result.success(savedNode)
} catch (e: Exception) {
logger.error("更新节点失败: ${e.message}", e)
@@ -295,6 +316,10 @@ class RpcNodeService(
return Result.failure(IllegalArgumentException("默认节点不允许删除"))
}
// 清理 RPC 缓存
logger.info("删除节点,清理 RPC 缓存: ${node.httpUrl}")
retrofitFactory.clearRpcApiCache(node.httpUrl)
rpcNodeConfigRepository.delete(node)
logger.info("成功删除 RPC 节点: ${node.name}")
Result.success(Unit)
@@ -84,5 +84,38 @@ object DateUtils {
}
return displayFormatter.format(instant)
}
/**
* 将时间间隔毫秒格式化为可读的字符串
* 格式X天X小时X分钟 X小时X分钟 X分钟
* 只显示有意义的单位不显示0值单位
*
* @param milliseconds 时间间隔毫秒
* @return 格式化的时间间隔字符串 "2天3小时15分钟""5小时30分钟""45分钟"
*/
fun formatDuration(milliseconds: Long): String {
if (milliseconds < 0) {
return "0分钟"
}
val totalSeconds = milliseconds / 1000
val days = totalSeconds / (24 * 60 * 60)
val hours = (totalSeconds % (24 * 60 * 60)) / (60 * 60)
val minutes = (totalSeconds % (60 * 60)) / 60
val parts = mutableListOf<String>()
if (days > 0) {
parts.add("${days}")
}
if (hours > 0) {
parts.add("${hours}小时")
}
if (minutes > 0 || parts.isEmpty()) {
parts.add("${minutes}分钟")
}
return parts.joinToString("")
}
}
@@ -0,0 +1,8 @@
-- ============================================
-- V26: 移除最大仓位数量配置字段
-- 从 copy_trading 表中删除 max_position_count 字段
-- ============================================
ALTER TABLE copy_trading
DROP COLUMN max_position_count;
@@ -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 '最后处理的交易索引(用于中断恢复)';
@@ -255,3 +255,54 @@ 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.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
# 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,60 @@ 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.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=重试回测任务失败
# 回测管理
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,54 @@ 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.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=重試回測任務失敗
# 回測管理
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=支持賣出
+4
View File
@@ -168,6 +168,10 @@ deploy() {
info "构建 Docker 镜像(本地构建,版本号: ${DOCKER_VERSION}..."
# 创建占位符目录(如果不存在),避免 Dockerfile COPY 失败
# 当 BUILD_IN_DOCKER=true 时,backend/build 可能不存在
mkdir -p backend/build/libs
# 设置构建参数(通过环境变量传递给 docker-compose.yml
export VERSION=${DOCKER_VERSION}
export GIT_TAG=${DOCKER_VERSION}
+86 -10
View File
@@ -331,10 +331,17 @@ def perform_update(target_version):
# 7. 重启后端服务
update_status['message'] = '重启后端服务...'
logger.info("重启后端服务...")
subprocess.Popen([
# 创建后端日志文件
backend_log_file = LOG_FILE.parent / 'backend-update.log'
backend_log = open(backend_log_file, 'w')
backend_process = subprocess.Popen([
'java', '-jar', str(BACKEND_JAR),
'--spring.profiles.active=prod'
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
], stdout=backend_log, stderr=subprocess.STDOUT, start_new_session=True)
logger.info(f"后端进程已启动 (PID: {backend_process.pid})")
update_status['progress'] = 80
@@ -349,32 +356,101 @@ def perform_update(target_version):
logger.info("等待后端服务启动...")
healthy = False
for i in range(30):
max_wait_time = 90 # 增加到90秒,给后端更多启动时间
last_process_check = 0
for i in range(max_wait_time):
# 每5秒检查一次进程状态
if i - last_process_check >= 5:
last_process_check = i
if backend_process.poll() is not None:
# 进程已退出
backend_log.close()
error_msg = ''
try:
with open(backend_log_file, 'r') as f:
lines = f.readlines()
error_msg = ''.join(lines[-50:]) # 读取最后50行
except:
pass
logger.error(f"后端进程异常退出(等待了 {i} 秒),退出码: {backend_process.returncode}")
if error_msg:
logger.error(f"后端日志最后50行:\n{error_msg}")
raise Exception(f"后端服务启动失败,退出码: {backend_process.returncode}")
else:
logger.debug(f"后端进程仍在运行 (PID: {backend_process.pid})")
# 尝试健康检查
try:
response = requests.get(f'{BACKEND_URL}/api/system/health', timeout=2)
if response.status_code == 200:
healthy = True
backend_log.close()
logger.info(f"健康检查通过(等待了 {i+1} 秒)")
break
except:
pass
except requests.exceptions.ConnectionError:
# 连接被拒绝,说明后端还没启动或端口未监听
if i % 10 == 0 and i > 0: # 每10秒记录一次
logger.debug(f"健康检查尝试 {i+1}/{max_wait_time}: 连接被拒绝(后端可能还在启动中)")
except requests.exceptions.Timeout:
# 超时
if i % 10 == 0: # 每10秒记录一次
logger.debug(f"健康检查尝试 {i+1}/{max_wait_time}: 请求超时")
except Exception as e:
logger.warning(f"健康检查异常: {e}")
time.sleep(1)
if not healthy:
# 关闭日志文件并尝试读取错误信息
backend_log.close()
error_msg = ''
try:
with open(backend_log_file, 'r') as f:
lines = f.readlines()
error_msg = ''.join(lines[-100:]) # 读取最后100行
except:
pass
# 检查进程状态
process_status = backend_process.poll()
if process_status is None:
# 进程还在运行,但健康检查失败
logger.error(f"健康检查失败:后端进程仍在运行 (PID: {backend_process.pid}),但无法访问健康检查端点")
logger.error("可能的原因:端口未监听、健康检查端点异常、或启动时间过长")
else:
# 进程已退出
logger.error(f"健康检查失败:后端进程已退出,退出码: {process_status}")
if error_msg:
logger.error(f"后端启动日志(最后100行):\n{error_msg}")
logger.error("健康检查失败,开始回滚...")
update_status['message'] = '健康检查失败,回滚中...'
# 确保后端进程已停止
try:
backend_process.terminate()
backend_process.wait(timeout=5)
except:
subprocess.run(['pkill', '-9', '-f', 'java.*app.jar'], check=False)
restore_backup(backup_dir)
# 重启后端
subprocess.run(['pkill', '-f', 'java -jar'], check=False)
time.sleep(1)
# 等待一下再重启
time.sleep(2)
# 重启后端(使用旧版本)
logger.info("重启旧版本后端服务...")
subprocess.Popen([
'java', '-jar', str(BACKEND_JAR),
'--spring.profiles.active=prod'
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
subprocess.run(['nginx', '-s', 'reload'], check=True)
raise Exception("健康检查失败,已回滚到旧版本")
raise Exception(f"健康检查失败(等待了 {max_wait_time} 秒),已回滚到旧版本。请查看日志文件 {backend_log_file} 了解详情")
update_status['progress'] = 100
update_status['message'] = f'更新成功:{tag}'
+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
+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>} />
+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') || '回测'
}
]
},
+172 -13
View File
@@ -4,6 +4,7 @@
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"viewDetail": "View Details",
"delete": "Delete",
"add": "Add",
"search": "Search",
@@ -33,7 +34,10 @@
"previous": "Previous",
"next": "Next",
"page": "Page",
"pageOf": "Page"
"pageOf": "Page",
"ascending": "Ascending",
"descending": "Descending",
"day": "day"
},
"account": {
"title": "Account Management",
@@ -239,6 +243,7 @@
"templates": "Templates",
"copyTradingConfig": "Copy Trading Config",
"positions": "Position Management",
"backtest": "Backtest",
"statistics": "Statistics",
"announcements": "Announcements",
"users": "User Management",
@@ -481,6 +486,37 @@
"fetchFailed": "Failed to get Leader details",
"invalidId": "Invalid Leader ID"
},
"leaderDetail": {
"title": "Leader Details",
"loading": "Loading...",
"invalidId": "Invalid Leader ID",
"fetchFailed": "Failed to get Leader details",
"fetchBalanceFailed": "Failed to get balance",
"noBalanceData": "No balance data",
"basicInfo": "Basic Information",
"leaderName": "Leader Name",
"leaderAddress": "Wallet Address",
"category": "Category",
"copyTradingCount": "Copy Trading Count",
"remark": "Remark",
"createdAt": "Created At",
"updatedAt": "Updated At",
"website": "Website",
"openWebsite": "Open Website",
"balanceInfo": "Balance Information",
"refresh": "Refresh",
"availableBalance": "Available Balance",
"positionBalance": "Position Asset",
"totalBalance": "Total Balance",
"positions": "Positions",
"noPositions": "No positions",
"marketId": "Market ID",
"side": "Side",
"quantity": "Quantity",
"avgPrice": "Avg Price",
"currentValue": "Current Value",
"pnl": "PnL"
},
"userList": {
"title": "User Management",
"username": "Username",
@@ -686,9 +722,6 @@
"maxPositionValue": "Max Position Value (USDC)",
"maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxPositionCount": "Max Position Count",
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
"supportSell": "Support Sell",
"supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.",
"pushFilteredOrders": "Push Filtered Orders",
@@ -760,9 +793,6 @@
"maxPositionValue": "Max Position Value (USDC)",
"maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxPositionCount": "Max Position Count",
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
"keywordFilter": "Keyword Filter",
"keywordFilterMode": "Filter Mode",
"keywordFilterModeTooltip": "Select keyword filter mode. Whitelist: only copy markets containing keywords; Blacklist: do not copy markets containing keywords; Disabled: no keyword filtering. Keyword matching is case-insensitive.",
@@ -808,7 +838,13 @@
"noAccounts": "No accounts",
"importAccount": "Import Account",
"noLeaders": "No Leaders",
"addLeader": "Add Leader"
"addLeader": "Add Leader",
"leaderAssetInfo": "Leader Asset Info",
"loadingAssetInfo": "Loading asset info...",
"totalAsset": "Total Assets",
"availableBalance": "Available Balance",
"positionAsset": "Position Assets",
"fetchAssetInfoFailed": "Failed to fetch asset info"
},
"copyTradingEdit": {
"title": "Edit Copy Trading Config",
@@ -867,9 +903,6 @@
"maxPositionValue": "Max Position Value (USDC)",
"maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxPositionCount": "Max Position Count",
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
"keywordFilter": "Keyword Filter",
"keywordFilterMode": "Filter Mode",
"keywordFilterModeTooltip": "Select keyword filter mode. Whitelist: only copy markets containing keywords; Blacklist: do not copy markets containing keywords; Disabled: no keyword filtering. Keyword matching is case-insensitive.",
@@ -934,11 +967,11 @@
"orderbookEmpty": "Orderbook Empty",
"priceRange": "Price Range Mismatch",
"maxPositionValue": "Exceeds Max Position Value",
"maxPositionCount": "Exceeds Max Position Count",
"marketEndDate": "Market End Date Exceeds Limit",
"unknown": "Unknown Reason"
},
"noData": "No filtered orders"
"noData": "No filtered orders",
"noFilteredOrders": "No filtered orders"
},
"copyTradingList": {
"title": "Copy Trading Config Management",
@@ -1197,5 +1230,131 @@
"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",
"keywordsPlaceholder": "Please enter keywords, press Enter to add",
"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"
}
}
+176 -15
View File
@@ -6,6 +6,7 @@
"confirm": "确定",
"delete": "删除",
"edit": "编辑",
"viewDetail": "查看详情",
"add": "添加",
"refresh": "刷新",
"search": "搜索",
@@ -33,7 +34,10 @@
"pageOf": "第",
"success": "成功",
"failed": "失败",
"close": "关闭"
"close": "关闭",
"ascending": "升序",
"descending": "降序",
"day": "天"
},
"login": {
"title": "登录",
@@ -216,7 +220,7 @@
"walletAddress": "钱包地址",
"category": "分类",
"all": "全部",
"copyTradingCount": "跟单关系数",
"copyTradingCount": "跟单数",
"createdAt": "创建时间",
"action": "操作",
"add": "添加",
@@ -239,6 +243,7 @@
"templates": "跟单模板",
"copyTradingConfig": "跟单配置",
"positions": "仓位管理",
"backtest": "回测",
"statistics": "统计信息",
"announcements": "公告",
"users": "用户管理",
@@ -424,7 +429,7 @@
"website": "网站",
"openWebsite": "打开网页",
"all": "全部",
"copyTradingCount": "跟单关系数",
"copyTradingCount": "跟单数",
"copyTradingRelations": "{{count}} 个跟单关系",
"createdAt": "创建时间",
"noData": "暂无 Leader 数据",
@@ -481,6 +486,37 @@
"fetchFailed": "获取 Leader 详情失败",
"invalidId": "Leader ID 无效"
},
"leaderDetail": {
"title": "Leader 详情",
"loading": "加载中...",
"invalidId": "Leader ID 无效",
"fetchFailed": "获取 Leader 详情失败",
"fetchBalanceFailed": "获取余额失败",
"noBalanceData": "暂无余额数据",
"basicInfo": "基本信息",
"leaderName": "Leader 名称",
"leaderAddress": "钱包地址",
"category": "分类",
"copyTradingCount": "跟单数",
"remark": "备注",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"website": "网站",
"openWebsite": "打开网页",
"balanceInfo": "余额信息",
"refresh": "刷新",
"availableBalance": "可用余额",
"positionBalance": "仓位资产",
"totalBalance": "总余额",
"positions": "持仓列表",
"noPositions": "暂无持仓",
"marketId": "市场ID",
"side": "方向",
"quantity": "数量",
"avgPrice": "均价",
"currentValue": "当前价值",
"pnl": "盈亏"
},
"userList": {
"title": "用户管理",
"username": "用户名",
@@ -686,9 +722,6 @@
"maxPositionValue": "最大仓位金额 (USDC)",
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
"maxPositionCount": "最大仓位数量",
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"supportSell": "跟单卖出",
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
"pushFilteredOrders": "推送已过滤订单",
@@ -774,9 +807,6 @@
"maxPositionValue": "最大仓位金额 (USDC)",
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
"maxPositionCount": "最大仓位数量",
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"keywordFilter": "关键字过滤",
"keywordFilterMode": "过滤模式",
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
@@ -808,7 +838,13 @@
"noAccounts": "暂无账户",
"importAccount": "导入账户",
"noLeaders": "暂无 Leader",
"addLeader": "添加 Leader"
"addLeader": "添加 Leader",
"leaderAssetInfo": "Leader 资产信息",
"loadingAssetInfo": "加载资产信息中...",
"totalAsset": "总资产",
"availableBalance": "可用余额",
"positionAsset": "仓位资产",
"fetchAssetInfoFailed": "获取资产信息失败"
},
"copyTradingEdit": {
"title": "编辑跟单配置",
@@ -881,9 +917,6 @@
"maxPositionValue": "最大仓位金额 (USDC)",
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
"maxPositionCount": "最大仓位数量",
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"keywordFilter": "关键字过滤",
"keywordFilterMode": "过滤模式",
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
@@ -934,11 +967,11 @@
"orderbookEmpty": "订单簿为空",
"priceRange": "价格区间不符",
"maxPositionValue": "超过最大仓位金额",
"maxPositionCount": "超过最大仓位数量",
"marketEndDate": "市场截止时间超出限制",
"unknown": "未知原因"
},
"noData": "暂无已过滤订单"
"noData": "暂无已过滤订单",
"noFilteredOrders": "暂无已过滤订单"
},
"copyTradingList": {
"title": "跟单配置管理",
@@ -1116,6 +1149,8 @@
"orderCount": "订单数",
"totalAmount": "总金额",
"statusBreakdown": "状态",
"allFullyMatched": "全部卖出",
"partiallyMatched": "部分卖出",
"markets": "个市场",
"fetchBuyOrdersFailed": "获取买入订单列表失败",
"fetchSellOrdersFailed": "获取卖出订单列表失败",
@@ -1195,5 +1230,131 @@
"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": "价格过滤",
"keywordsPlaceholder": "请输入关键字,按回车添加",
"delaySecondsHint": "延迟执行模拟真实跟单延迟",
"supportSellHint": "是否跟随 Leader 卖出",
"sortBy": "排序字段",
"sortOrder": "排序顺序",
"createdAt": "创建时间"
}
}
+179 -18
View File
@@ -4,6 +4,7 @@
"save": "保存",
"cancel": "取消",
"edit": "編輯",
"viewDetail": "查看詳情",
"delete": "刪除",
"add": "添加",
"search": "搜索",
@@ -33,7 +34,10 @@
"previous": "上一頁",
"next": "下一頁",
"page": "頁",
"pageOf": "第"
"pageOf": "第",
"ascending": "升序",
"descending": "降序",
"day": "天"
},
"account": {
"title": "賬戶管理",
@@ -216,7 +220,7 @@
"walletAddress": "錢包地址",
"category": "分類",
"all": "全部",
"copyTradingCount": "跟單關係數",
"copyTradingCount": "跟單數",
"createdAt": "創建時間",
"action": "操作",
"add": "添加",
@@ -239,6 +243,7 @@
"templates": "跟單模板",
"copyTradingConfig": "跟單配置",
"positions": "倉位管理",
"backtest": "回測",
"statistics": "統計信息",
"announcements": "公告",
"users": "用戶管理",
@@ -424,7 +429,7 @@
"website": "網站",
"openWebsite": "打開網頁",
"all": "全部",
"copyTradingCount": "跟單關係數",
"copyTradingCount": "跟單數",
"copyTradingRelations": "{{count}} 個跟單關係",
"createdAt": "創建時間",
"noData": "暫無 Leader 數據",
@@ -481,6 +486,37 @@
"fetchFailed": "獲取 Leader 詳情失敗",
"invalidId": "Leader ID 無效"
},
"leaderDetail": {
"title": "Leader 詳情",
"loading": "加載中...",
"invalidId": "Leader ID 無效",
"fetchFailed": "獲取 Leader 詳情失敗",
"fetchBalanceFailed": "獲取餘額失敗",
"noBalanceData": "暫無餘額數據",
"basicInfo": "基本信息",
"leaderName": "Leader 名稱",
"leaderAddress": "錢包地址",
"category": "分類",
"copyTradingCount": "跟單數",
"remark": "備註",
"createdAt": "創建時間",
"updatedAt": "更新時間",
"website": "網站",
"openWebsite": "打開網頁",
"balanceInfo": "餘額信息",
"refresh": "刷新",
"availableBalance": "可用餘額",
"positionBalance": "倉位資產",
"totalBalance": "總餘額",
"positions": "持倉列表",
"noPositions": "暫無持倉",
"marketId": "市場ID",
"side": "方向",
"quantity": "數量",
"avgPrice": "均價",
"currentValue": "當前價值",
"pnl": "盈虧"
},
"userList": {
"title": "用戶管理",
"username": "用戶名",
@@ -686,9 +722,6 @@
"maxPositionValue": "最大倉位金額 (USDC)",
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxPositionCount": "最大倉位數量",
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"supportSell": "跟單賣出",
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
"pushFilteredOrders": "推送已過濾訂單",
@@ -760,9 +793,6 @@
"maxPositionValue": "最大倉位金額 (USDC)",
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxPositionCount": "最大倉位數量",
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"keywordFilter": "關鍵字過濾",
"keywordFilterMode": "過濾模式",
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
@@ -805,10 +835,16 @@
"fetchLeaderFailed": "獲取 Leader 列表失敗",
"fetchTemplateFailed": "獲取模板列表失敗",
"templateName": "模板名稱",
"noAccounts": "暫無戶",
"importAccount": "導入戶",
"noAccounts": "暫無戶",
"importAccount": "導入戶",
"noLeaders": "暫無 Leader",
"addLeader": "添加 Leader"
"addLeader": "添加 Leader",
"leaderAssetInfo": "Leader 資產資訊",
"loadingAssetInfo": "加載資產資訊中...",
"totalAsset": "總資產",
"availableBalance": "可用餘額",
"positionAsset": "倉位資產",
"fetchAssetInfoFailed": "獲取資產資訊失敗"
},
"copyTradingEdit": {
"title": "編輯跟單配置",
@@ -867,9 +903,6 @@
"maxPositionValue": "最大倉位金額 (USDC)",
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxPositionCount": "最大倉位數量",
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"keywordFilter": "關鍵字過濾",
"keywordFilterMode": "過濾模式",
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
@@ -934,11 +967,11 @@
"orderbookEmpty": "訂單簿為空",
"priceRange": "價格區間不符",
"maxPositionValue": "超過最大倉位金額",
"maxPositionCount": "超過最大倉位數量",
"marketEndDate": "市場截止時間超出限制",
"unknown": "未知原因"
},
"noData": "暫無已過濾訂單"
"noData": "暫無已過濾訂單",
"noFilteredOrders": "暫無已過濾訂單"
},
"copyTradingList": {
"title": "跟單配置管理",
@@ -1115,8 +1148,10 @@
"partiallySold": "部分賣出",
"orderCount": "訂單數",
"totalAmount": "總金額",
"totalPnl": "總盈虧",
"statusBreakdown": "狀態",
"allFullyMatched": "全部賣出",
"partiallyMatched": "部分賣出",
"totalPnl": "總盈虧",
"markets": "個市場",
"fetchBuyOrdersFailed": "獲取買入訂單列表失敗",
"fetchSellOrdersFailed": "獲取賣出訂單列表失敗",
@@ -1195,5 +1230,131 @@
"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": "價格過濾",
"keywordsPlaceholder": "請輸入關鍵字,按回車添加",
"delaySecondsHint": "延遲執行模擬真實跟單延遲",
"supportSellHint": "是否跟隨 Leader 賣出",
"sortBy": "排序欄位",
"sortOrder": "排序順序",
"createdAt": "創建時間"
}
}
+113 -113
View File
@@ -26,18 +26,18 @@ const AccountList: React.FC = () => {
const [editLoading, setEditLoading] = useState(false)
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
const [accountImportForm] = Form.useForm()
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
const handleAccountImportSuccess = async () => {
message.success(t('accountImport.importSuccess'))
setAccountImportModalVisible(false)
accountImportForm.resetFields()
fetchAccounts()
}
// 加载所有账户的余额
useEffect(() => {
const loadBalances = async () => {
@@ -46,8 +46,8 @@ const AccountList: React.FC = () => {
setBalanceLoading(prev => ({ ...prev, [account.id]: true }))
try {
const balanceData = await fetchAccountBalance(account.id)
setBalanceMap(prev => ({
...prev,
setBalanceMap(prev => ({
...prev,
[account.id]: {
total: balanceData.totalBalance || '0',
available: balanceData.availableBalance || '0',
@@ -56,8 +56,8 @@ const AccountList: React.FC = () => {
}))
} catch (error) {
console.error(`获取账户 ${account.id} 余额失败:`, error)
setBalanceMap(prev => ({
...prev,
setBalanceMap(prev => ({
...prev,
[account.id]: { total: '-', available: '-', position: '-' }
}))
} finally {
@@ -66,12 +66,12 @@ const AccountList: React.FC = () => {
}
}
}
if (accounts.length > 0) {
loadBalances()
}
}, [accounts])
const handleDelete = async (account: Account) => {
try {
await deleteAccount(account.id)
@@ -80,13 +80,13 @@ const AccountList: React.FC = () => {
message.error(error.message || t('accountList.deleteFailed'))
}
}
const handleCopy = (text: string) => {
if (!text) {
message.warning(t('accountList.copyFailed') || '复制失败:地址为空')
return
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
message.success({
@@ -103,7 +103,7 @@ const AccountList: React.FC = () => {
fallbackCopyTextToClipboard(text)
}
}
const fallbackCopyTextToClipboard = (text: string) => {
const textArea = document.createElement('textarea')
textArea.value = text
@@ -113,7 +113,7 @@ const AccountList: React.FC = () => {
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
@@ -131,19 +131,19 @@ const AccountList: React.FC = () => {
document.body.removeChild(textArea)
}
}
const handleShowDetail = async (account: Account) => {
try {
setDetailModalVisible(true)
setDetailAccount(account)
setDetailBalance(null)
setDetailBalanceLoading(false)
// 加载详情和余额
try {
const accountDetail = await fetchAccountDetail(account.id)
setDetailAccount(accountDetail)
// 加载余额
setDetailBalanceLoading(true)
try {
@@ -173,10 +173,10 @@ const AccountList: React.FC = () => {
setDetailAccount(null)
}
}
const handleRefreshDetailBalance = async () => {
if (!detailAccount) return
setDetailBalanceLoading(true)
try {
const balanceData = await fetchAccountBalance(detailAccount.id)
@@ -193,16 +193,16 @@ const AccountList: React.FC = () => {
setDetailBalanceLoading(false)
}
}
const handleShowEdit = async (account: Account) => {
try {
setEditModalVisible(true)
setEditAccount(account)
// 加载账户详情并设置表单初始值
const accountDetail = await fetchAccountDetail(account.id)
setEditAccount(accountDetail)
editForm.setFieldsValue({
accountName: accountDetail.accountName || '',
apiKey: '', // 不显示实际值,留空表示不修改
@@ -216,10 +216,10 @@ const AccountList: React.FC = () => {
setEditAccount(null)
}
}
const handleEditSubmit = async (values: any) => {
if (!editAccount) return
setEditLoading(true)
try {
// 构建更新请求,只支持编辑账户名称
@@ -227,17 +227,17 @@ const AccountList: React.FC = () => {
accountId: editAccount.id,
accountName: values.accountName || undefined
}
await updateAccount(updateData)
message.success(t('accountList.updateSuccess'))
setEditModalVisible(false)
setEditAccount(null)
editForm.resetFields()
// 刷新账户列表
await fetchAccounts()
// 如果详情 Modal 打开着,也刷新详情
if (detailModalVisible && detailAccount && detailAccount.id === editAccount.id) {
const accountDetail = await fetchAccountDetail(editAccount.id)
@@ -249,7 +249,7 @@ const AccountList: React.FC = () => {
setEditLoading(false)
}
}
const columns = [
{
title: t('accountList.accountName'),
@@ -339,7 +339,7 @@ const AccountList: React.FC = () => {
<Popconfirm
title={t('accountList.deleteConfirm')}
description={
record.apiKeyConfigured
record.apiKeyConfigured
? t('accountList.deleteConfirmDesc')
: t('accountList.deleteConfirmDescSimple')
}
@@ -356,7 +356,7 @@ const AccountList: React.FC = () => {
)
}
]
const mobileColumns = [
{
title: t('accountList.accountName'),
@@ -364,16 +364,16 @@ const AccountList: React.FC = () => {
render: (_: any, record: Account) => {
return (
<div style={{ padding: '8px 0' }}>
<div style={{
fontWeight: 'bold',
<div style={{
fontWeight: 'bold',
marginBottom: '8px',
fontSize: '16px'
}}>
{record.accountName || `${t('accountList.accountName')} ${record.id}`}
</div>
<div style={{
fontSize: '11px',
color: '#666',
<div style={{
fontSize: '11px',
color: '#666',
marginBottom: '8px',
wordBreak: 'break-all',
fontFamily: 'monospace',
@@ -406,7 +406,7 @@ const AccountList: React.FC = () => {
/>
</div>
</div>
<div style={{
<div style={{
fontSize: '14px',
fontWeight: '500',
color: '#1890ff'
@@ -420,7 +420,7 @@ const AccountList: React.FC = () => {
)}
</div>
{balanceMap[record.id] && balanceMap[record.id].available !== '-' && (
<div style={{
<div style={{
fontSize: '12px',
color: '#666',
marginTop: '4px'
@@ -459,7 +459,7 @@ const AccountList: React.FC = () => {
<Popconfirm
title={t('accountList.deleteConfirm')}
description={
record.apiKeyConfigured
record.apiKeyConfigured
? t('accountList.deleteConfirmDesc')
: t('accountList.deleteConfirmDescSimple')
}
@@ -468,9 +468,9 @@ const AccountList: React.FC = () => {
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
>
<Button
size="small"
block
<Button
size="small"
block
danger
style={{ minHeight: '32px' }}
>
@@ -481,15 +481,15 @@ const AccountList: React.FC = () => {
)
}
]
return (
<div style={{
<div style={{
padding: isMobile ? '0' : undefined,
margin: isMobile ? '0 -8px' : undefined
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: isMobile ? '12px' : '16px',
flexWrap: 'wrap',
@@ -510,8 +510,8 @@ const AccountList: React.FC = () => {
{t('accountList.importAccount')}
</Button>
</div>
<Card style={{
<Card style={{
margin: isMobile ? '0 -8px' : '0',
borderRadius: isMobile ? '0' : undefined
}}>
@@ -544,7 +544,7 @@ const AccountList: React.FC = () => {
/>
)}
</Card>
{/* 账户详情 Modal */}
<Modal
title={detailAccount ? (detailAccount.accountName || `${t('accountList.accountName')} ${detailAccount.id}`) : t('accountList.accountDetail')}
@@ -555,19 +555,19 @@ const AccountList: React.FC = () => {
setDetailBalance(null)
}}
footer={[
<Button
key="refresh"
icon={<ReloadOutlined />}
onClick={handleRefreshDetailBalance}
<Button
key="refresh"
icon={<ReloadOutlined />}
onClick={handleRefreshDetailBalance}
loading={detailBalanceLoading}
disabled={!detailAccount}
>
{t('accountList.refreshBalance')}
</Button>,
<Button
key="edit"
<Button
key="edit"
type="primary"
icon={<EditOutlined />}
icon={<EditOutlined />}
onClick={() => {
if (detailAccount) {
setDetailModalVisible(false)
@@ -578,8 +578,8 @@ const AccountList: React.FC = () => {
>
{t('accountList.edit')}
</Button>,
<Button
key="close"
<Button
key="close"
onClick={() => {
setDetailModalVisible(false)
setDetailAccount(null)
@@ -610,8 +610,8 @@ const AccountList: React.FC = () => {
</Descriptions.Item>
<Descriptions.Item label={t('accountList.walletAddress')} span={isMobile ? 1 : 2}>
<Space>
<span style={{
fontFamily: 'monospace',
<span style={{
fontFamily: 'monospace',
fontSize: isMobile ? '11px' : '13px',
wordBreak: 'break-all',
lineHeight: '1.4',
@@ -633,8 +633,8 @@ const AccountList: React.FC = () => {
</Descriptions.Item>
<Descriptions.Item label={t('accountList.proxyAddress')} span={isMobile ? 1 : 2}>
<Space>
<span style={{
fontFamily: 'monospace',
<span style={{
fontFamily: 'monospace',
fontSize: isMobile ? '11px' : '13px',
wordBreak: 'break-all',
lineHeight: '1.4',
@@ -688,9 +688,9 @@ const AccountList: React.FC = () => {
)}
</Descriptions.Item>
</Descriptions>
<Divider />
<Descriptions
column={isMobile ? 1 : 2}
bordered
@@ -720,51 +720,51 @@ const AccountList: React.FC = () => {
)}
</Descriptions.Item>
</Descriptions>
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
detailAccount.activeOrders !== undefined ||
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
detailAccount.activeOrders !== undefined ||
detailAccount.completedOrders !== undefined || detailAccount.positionCount !== undefined) && (
<>
<Divider />
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
title={t('accountList.statistics')}
>
{detailAccount.totalOrders !== undefined && (
<Descriptions.Item label={t('accountList.totalOrders')}>
{detailAccount.totalOrders}
</Descriptions.Item>
)}
{detailAccount.activeOrders !== undefined && (
<Descriptions.Item label={t('accountList.activeOrdersCount')}>
<Tag color={detailAccount.activeOrders > 0 ? 'orange' : 'default'}>{detailAccount.activeOrders}</Tag>
</Descriptions.Item>
)}
{detailAccount.completedOrders !== undefined && (
<Descriptions.Item label={t('accountList.completedOrders')}>
<Tag color="success">{detailAccount.completedOrders}</Tag>
</Descriptions.Item>
)}
{detailAccount.positionCount !== undefined && (
<Descriptions.Item label={t('accountList.positionCount')}>
<Tag color={detailAccount.positionCount > 0 ? 'blue' : 'default'}>{detailAccount.positionCount}</Tag>
</Descriptions.Item>
)}
{detailAccount.totalPnl !== undefined && (
<Descriptions.Item label={t('accountList.totalPnl')}>
<span style={{
fontWeight: 'bold',
color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
}}>
{formatUSDC(detailAccount.totalPnl)} USDC
</span>
</Descriptions.Item>
)}
</Descriptions>
</>
)}
<>
<Divider />
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
title={t('accountList.statistics')}
>
{detailAccount.totalOrders !== undefined && (
<Descriptions.Item label={t('accountList.totalOrders')}>
{detailAccount.totalOrders}
</Descriptions.Item>
)}
{detailAccount.activeOrders !== undefined && (
<Descriptions.Item label={t('accountList.activeOrdersCount')}>
<Tag color={detailAccount.activeOrders > 0 ? 'orange' : 'default'}>{detailAccount.activeOrders}</Tag>
</Descriptions.Item>
)}
{detailAccount.completedOrders !== undefined && (
<Descriptions.Item label={t('accountList.completedOrders')}>
<Tag color="success">{detailAccount.completedOrders}</Tag>
</Descriptions.Item>
)}
{detailAccount.positionCount !== undefined && (
<Descriptions.Item label={t('accountList.positionCount')}>
<Tag color={detailAccount.positionCount > 0 ? 'blue' : 'default'}>{detailAccount.positionCount}</Tag>
</Descriptions.Item>
)}
{detailAccount.totalPnl !== undefined && (
<Descriptions.Item label={t('accountList.totalPnl')}>
<span style={{
fontWeight: 'bold',
color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
}}>
{formatUSDC(detailAccount.totalPnl)} USDC
</span>
</Descriptions.Item>
)}
</Descriptions>
</>
)}
</div>
) : (
<div style={{ textAlign: 'center', padding: '20px' }}>
@@ -773,7 +773,7 @@ const AccountList: React.FC = () => {
</div>
)}
</Modal>
{/* 编辑账户 Modal */}
<Modal
title={editAccount ? `${t('accountList.editAccount')} - ${editAccount.accountName || `${t('accountList.accountName')} ${editAccount.id}`}` : t('accountList.editAccount')}
@@ -804,17 +804,17 @@ const AccountList: React.FC = () => {
showIcon
style={{ marginBottom: '24px' }}
/>
<Form.Item
label={t('accountList.accountName') || '账户名称'}
name="accountName"
>
<Input placeholder={t('accountList.accountNamePlaceholder') || '请输入账户名称(可选)'} />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button
<Button
onClick={() => {
setEditModalVisible(false)
setEditAccount(null)
@@ -844,7 +844,7 @@ const AccountList: React.FC = () => {
</div>
)}
</Modal>
{/* 导入账户 Modal */}
<Modal
title={t('accountImport.title')}
+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
+167 -22
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef } from 'react'
import { Modal, Form, Button, Switch, message, Space, Radio, InputNumber, Table, Select, Divider, Input, Tag, InputRef } from 'antd'
import { Modal, Form, Button, Switch, message, Space, Radio, InputNumber, Table, Select, Divider, Input, Tag, InputRef, Card, Row, Col, Statistic, Spin } from 'antd'
import { SaveOutlined, FileTextOutlined, PlusOutlined } from '@ant-design/icons'
import { apiService } from '../../services/api'
import { useAccountStore } from '../../store/accountStore'
@@ -16,12 +16,27 @@ 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[]
configName?: string
}
}
const AddModal: React.FC<AddModalProps> = ({
open,
onClose,
onSuccess
onSuccess,
preFilledConfig
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
@@ -36,6 +51,8 @@ const AddModal: React.FC<AddModalProps> = ({
const keywordInputRef = useRef<InputRef>(null)
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR')
const [leaderAssetInfo, setLeaderAssetInfo] = useState<{ total: string; available: string; position: string } | null>(null)
const [loadingAssetInfo, setLoadingAssetInfo] = useState(false)
// 导入账户modal相关状态
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
@@ -62,25 +79,112 @@ 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'
}
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 {
@@ -120,7 +224,6 @@ const AddModal: React.FC<AddModalProps> = ({
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined,
maxPositionValue: (template as any).maxPositionValue ? parseFloat((template as any).maxPositionValue) : undefined,
maxPositionCount: (template as any).maxPositionCount,
pushFilteredOrders: template.pushFilteredOrders ?? false
})
setCopyMode(template.copyMode)
@@ -249,7 +352,6 @@ const AddModal: React.FC<AddModalProps> = ({
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString(),
maxPositionValue: values.maxPositionValue?.toString(),
maxPositionCount: values.maxPositionCount,
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
? keywords
@@ -382,6 +484,7 @@ const AddModal: React.FC<AddModalProps> = ({
</div>
) : null
}
onChange={(value) => fetchLeaderAssetInfo(value)}
>
{leaders.map(leader => (
<Option key={leader.id} value={leader.id}>
@@ -391,6 +494,61 @@ const AddModal: React.FC<AddModalProps> = ({
</Select>
</Form.Item>
{/* Leader 资产信息 */}
{leaderAssetInfo && (
<Card
title={
<Space>
<span>{t('copyTradingAdd.leaderAssetInfo') || 'Leader 资产信息'}</span>
</Space>
}
size="small"
style={{ marginBottom: '16px', backgroundColor: '#f5f5f5', border: '1px solid #d9d9d9' }}
>
{loadingAssetInfo ? (
<div style={{ textAlign: 'center', padding: '24px' }}>
<Spin />
<div style={{ marginTop: '8px', color: '#999' }}>
{t('copyTradingAdd.loadingAssetInfo') || '加载资产信息中...'}
</div>
</div>
) : (
<Row gutter={16}>
<Col span={8}>
<Statistic
title={t('copyTradingAdd.totalAsset') || '总资产'}
value={parseFloat(leaderAssetInfo.total)}
precision={4}
valueStyle={{ color: '#52c41a', fontWeight: 'bold', fontSize: '16px' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Col>
<Col span={8}>
<Statistic
title={t('copyTradingAdd.availableBalance') || '可用余额'}
value={parseFloat(leaderAssetInfo.available)}
precision={4}
valueStyle={{ color: '#1890ff', fontSize: '14px' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Col>
<Col span={8}>
<Statistic
title={t('copyTradingAdd.positionAsset') || '仓位资产'}
value={parseFloat(leaderAssetInfo.position)}
precision={4}
valueStyle={{ color: '#722ed1', fontSize: '14px' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Col>
</Row>
)}
</Card>
)}
{/* 模板填充按钮 */}
<Form.Item>
<Button
@@ -716,19 +874,6 @@ const AddModal: React.FC<AddModalProps> = ({
/>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.maxPositionCount') || '最大仓位数量'}
name="maxPositionCount"
tooltip={t('copyTradingAdd.maxPositionCountTooltip') || '限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制'}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingAdd.maxPositionCountPlaceholder') || '例如:10(可选,不填写表示不启用)'}
/>
</Form.Item>
<Divider>{t('copyTradingAdd.keywordFilter') || '关键字过滤'}</Divider>
<Form.Item
@@ -1,9 +1,10 @@
import React, { useEffect, useState, useRef } from 'react'
import { Modal, Form, Button, message, Radio, InputNumber, Divider, Spin, Select, Input, Space, Switch, Tag, InputRef } from 'antd'
import { Modal, Form, Button, message, Radio, InputNumber, Divider, Spin, Select, Input, Space, Switch, Tag, InputRef, Card, Row, Col, Statistic } from 'antd'
import { SaveOutlined } from '@ant-design/icons'
import { apiService } from '../../services/api'
import type { CopyTrading, CopyTradingUpdateRequest } from '../../types'
import { useTranslation } from 'react-i18next'
import { formatUSDC } from '../../utils'
const { Option } = Select
@@ -26,11 +27,13 @@ const EditModal: React.FC<EditModalProps> = ({
const [fetching, setFetching] = useState(true)
const [copyTrading, setCopyTrading] = useState<CopyTrading | null>(null)
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
const [keywords, setKeywords] = useState<string[]>([])
const keywordInputRef = useRef<InputRef>(null)
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR')
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
const [keywords, setKeywords] = useState<string[]>([])
const keywordInputRef = useRef<InputRef>(null)
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR')
const [leaderAssetInfo, setLeaderAssetInfo] = useState<{ total: string; available: string; position: string } | null>(null)
const [loadingAssetInfo, setLoadingAssetInfo] = useState(false)
useEffect(() => {
if (open && copyTradingId) {
@@ -88,7 +91,6 @@ const EditModal: React.FC<EditModalProps> = ({
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
maxPositionValue: found.maxPositionValue ? parseFloat(found.maxPositionValue) : undefined,
maxPositionCount: found.maxPositionCount,
keywordFilterMode: found.keywordFilterMode || 'DISABLED',
configName: found.configName || '',
pushFailedOrders: found.pushFailedOrders ?? false,
@@ -96,6 +98,9 @@ const EditModal: React.FC<EditModalProps> = ({
})
// 设置关键字列表
setKeywords(found.keywords || [])
// 获取 Leader 资产信息
fetchLeaderAssetInfo(found.leaderId)
} else {
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
onClose()
@@ -116,6 +121,30 @@ const EditModal: React.FC<EditModalProps> = ({
setCopyMode(mode)
}
// 获取 Leader 资产信息
const fetchLeaderAssetInfo = async (leaderId: number) => {
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 handleAddKeyword = (e?: React.KeyboardEvent<HTMLInputElement>) => {
let inputValue = ''
@@ -207,8 +236,6 @@ const EditModal: React.FC<EditModalProps> = ({
minPrice: values.minPrice != null ? values.minPrice.toString() : '',
maxPrice: values.maxPrice != null ? values.maxPrice.toString() : '',
maxPositionValue: values.maxPositionValue != null ? values.maxPositionValue.toString() : '',
// 对于 maxPositionCount,如果值为 null/undefined,传 -1 表示要清空(后端会识别并设置为 null)
maxPositionCount: values.maxPositionCount != null ? values.maxPositionCount : -1,
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
? keywords
@@ -301,6 +328,59 @@ const EditModal: React.FC<EditModalProps> = ({
</Select>
</Form.Item>
{/* Leader 资产信息 */}
<Card
title={
<Space>
<span>{t('copyTradingAdd.leaderAssetInfo') || 'Leader 资产信息'}</span>
</Space>
}
size="small"
style={{ marginBottom: '16px', backgroundColor: '#f5f5f5', border: '1px solid #d9d9d9' }}
>
{loadingAssetInfo ? (
<div style={{ textAlign: 'center', padding: '24px' }}>
<Spin />
<div style={{ marginTop: '8px', color: '#999' }}>
{t('copyTradingAdd.loadingAssetInfo') || '加载资产信息中...'}
</div>
</div>
) : leaderAssetInfo ? (
<Row gutter={16}>
<Col span={8}>
<Statistic
title={t('copyTradingAdd.totalAsset') || '总资产'}
value={parseFloat(leaderAssetInfo.total)}
precision={4}
valueStyle={{ color: '#52c41a', fontWeight: 'bold', fontSize: '16px' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Col>
<Col span={8}>
<Statistic
title={t('copyTradingAdd.availableBalance') || '可用余额'}
value={parseFloat(leaderAssetInfo.available)}
precision={4}
valueStyle={{ color: '#1890ff', fontSize: '14px' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Col>
<Col span={8}>
<Statistic
title={t('copyTradingAdd.positionAsset') || '仓位资产'}
value={parseFloat(leaderAssetInfo.position)}
precision={4}
valueStyle={{ color: '#722ed1', fontSize: '14px' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Col>
</Row>
) : null}
</Card>
<Divider>{t('copyTradingEdit.basicConfig') || '基础配置'}</Divider>
<Form.Item
@@ -639,19 +719,6 @@ const EditModal: React.FC<EditModalProps> = ({
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxPositionCount') || '最大仓位数量'}
name="maxPositionCount"
tooltip={t('copyTradingEdit.maxPositionCountTooltip') || '限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制'}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxPositionCountPlaceholder') || '例如:10(可选,不填写表示不启用)'}
/>
</Form.Item>
{/* 关键字过滤 */}
<Divider>{t('copyTradingEdit.keywordFilter') || t('copyTradingAdd.keywordFilter') || '关键字过滤'}</Divider>
@@ -69,7 +69,6 @@ const FilteredOrdersModal: React.FC<FilteredOrdersModalProps> = ({
ORDERBOOK_EMPTY: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
PRICE_RANGE: { color: 'purple', text: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' },
MAX_POSITION_VALUE: { color: 'volcano', text: t('filteredOrdersList.filterTypes.maxPositionValue') || '超过最大仓位金额' },
MAX_POSITION_COUNT: { color: 'volcano', text: t('filteredOrdersList.filterTypes.maxPositionCount') || '超过最大仓位数量' },
MARKET_END_DATE: { color: 'cyan', text: t('filteredOrdersList.filterTypes.marketEndDate') || '市场截止时间超出限制' },
KEYWORD_FILTER: { color: 'geekblue', text: t('filteredOrdersList.filterTypes.keywordFilter') || '关键字过滤' }
}
+22 -22
View File
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'
import { Card, Row, Col, Statistic, Tag, Button, message, Spin } from 'antd'
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { formatUSDC } from '../utils'
import { formatUSDC, formatNumber } from '../utils'
import { useMediaQuery } from 'react-responsive'
import type { CopyTradingStatistics } from '../types'
@@ -13,16 +13,16 @@ const CopyTradingStatisticsPage: React.FC = () => {
useMediaQuery({ maxWidth: 768 }) // 用于响应式布局,但当前页面未使用
const [loading, setLoading] = useState(false)
const [statistics, setStatistics] = useState<CopyTradingStatistics | null>(null)
useEffect(() => {
if (copyTradingId) {
fetchStatistics()
}
}, [copyTradingId])
const fetchStatistics = async () => {
if (!copyTradingId) return
setLoading(true)
try {
const response = await apiService.statistics.detail({ copyTradingId: parseInt(copyTradingId) })
@@ -37,25 +37,25 @@ const CopyTradingStatisticsPage: React.FC = () => {
setLoading(false)
}
}
const getPnlColor = (value: string): string => {
const num = parseFloat(value)
if (isNaN(num)) return '#666'
return num >= 0 ? '#3f8600' : '#cf1322'
}
const getPnlIcon = (value: string) => {
const num = parseFloat(value)
if (isNaN(num)) return null
return num >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />
}
const formatPercent = (value: string): string => {
const num = parseFloat(value)
if (isNaN(num)) return '-'
return `${num >= 0 ? '+' : ''}${num.toFixed(2)}%`
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
@@ -63,7 +63,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
</div>
)
}
if (!statistics) {
return (
<Card>
@@ -74,7 +74,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
</Card>
)
}
return (
<div>
<Card style={{ marginBottom: 16 }}>
@@ -98,7 +98,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
</div>
</div>
</Card>
{/* 基本信息卡片 */}
<Card title="基本信息" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
@@ -130,14 +130,14 @@ const CopyTradingStatisticsPage: React.FC = () => {
</Col>
</Row>
</Card>
{/* 买入统计卡片 */}
<Card title="买入统计" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={6}>
<Statistic
title="总买入数量"
value={formatUSDC(statistics.totalBuyQuantity)}
value={formatNumber(statistics.totalBuyQuantity, 4)}
suffix=""
/>
</Col>
@@ -151,27 +151,27 @@ const CopyTradingStatisticsPage: React.FC = () => {
<Col xs={24} sm={12} md={6}>
<Statistic
title="总买入订单数"
value={statistics.totalBuyOrders}
value={formatNumber(statistics.totalBuyOrders)}
suffix="笔"
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic
title="平均买入价格"
value={formatUSDC(statistics.avgBuyPrice)}
value={formatNumber(statistics.avgBuyPrice, 4)}
suffix=""
/>
</Col>
</Row>
</Card>
{/* 卖出统计卡片 */}
<Card title="卖出统计" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8}>
<Statistic
title="总卖出数量"
value={formatUSDC(statistics.totalSellQuantity)}
value={formatNumber(statistics.totalSellQuantity, 4)}
suffix=""
/>
</Col>
@@ -185,33 +185,33 @@ const CopyTradingStatisticsPage: React.FC = () => {
<Col xs={24} sm={12} md={8}>
<Statistic
title="总卖出订单数"
value={statistics.totalSellOrders}
value={formatNumber(statistics.totalSellOrders)}
suffix="笔"
/>
</Col>
</Row>
</Card>
{/* 持仓统计卡片 */}
<Card title="持仓统计" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={12}>
<Statistic
title="当前持仓数量"
value={formatUSDC(statistics.currentPositionQuantity)}
value={formatNumber(statistics.currentPositionQuantity, 4)}
suffix=""
/>
</Col>
<Col xs={24} sm={12} md={12}>
<Statistic
title="平均买入价格"
value={formatUSDC(statistics.avgBuyPrice)}
value={formatNumber(statistics.avgBuyPrice, 4)}
suffix=""
/>
</Col>
</Row>
</Card>
{/* 盈亏统计卡片 */}
<Card title="盈亏统计">
<Row gutter={[16, 16]}>
@@ -66,7 +66,6 @@ const FilteredOrdersList: React.FC = () => {
'ORDERBOOK_EMPTY': { color: 'default', label: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
'PRICE_RANGE': { color: 'purple', label: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' },
'MAX_POSITION_VALUE': { color: 'volcano', label: t('filteredOrdersList.filterTypes.maxPositionValue') || '超过最大仓位金额' },
'MAX_POSITION_COUNT': { color: 'volcano', label: t('filteredOrdersList.filterTypes.maxPositionCount') || '超过最大仓位数量' },
'MARKET_END_DATE': { color: 'cyan', label: t('filteredOrdersList.filterTypes.marketEndDate') || '市场截止时间超出限制' },
'KEYWORD_FILTER': { color: 'geekblue', label: t('filteredOrdersList.filterTypes.keywordFilter') || '关键字过滤' },
'UNKNOWN': { color: 'default', label: t('filteredOrdersList.filterTypes.unknown') || '未知原因' }
+423 -179
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined } from '@ant-design/icons'
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
import type { Leader } from '../types'
import type { Leader, LeaderBalanceResponse } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
const { Text } = Typography
@@ -15,11 +16,19 @@ const LeaderList: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const [leaders, setLeaders] = useState<Leader[]>([])
const [loading, setLoading] = useState(false)
const [balanceMap, setBalanceMap] = useState<Record<number, { total: string; available: string; position: string }>>({})
const [balanceLoading, setBalanceLoading] = useState<Record<number, boolean>>({})
// 详情 Modal
const [detailModalVisible, setDetailModalVisible] = useState(false)
const [detailLeader, setDetailLeader] = useState<Leader | null>(null)
const [detailBalance, setDetailBalance] = useState<LeaderBalanceResponse | null>(null)
const [detailBalanceLoading, setDetailBalanceLoading] = useState(false)
useEffect(() => {
fetchLeaders()
}, [])
const fetchLeaders = async () => {
setLoading(true)
try {
@@ -27,253 +36,346 @@ const LeaderList: React.FC = () => {
if (response.data.code === 0 && response.data.data) {
setLeaders(response.data.data.list || [])
} else {
message.error(response.data.msg || t('leaderList.fetchFailed') || '获取 Leader 列表失败')
message.error(response.data.msg || t('leaderList.fetchFailed'))
}
} catch (error: any) {
message.error(error.message || t('leaderList.fetchFailed') || '获取 Leader 列表失败')
message.error(error.message || t('leaderList.fetchFailed'))
} finally {
setLoading(false)
}
}
// 加载所有 Leader 的余额
useEffect(() => {
const loadBalances = async () => {
for (const leader of leaders) {
if (!balanceMap[leader.id] && !balanceLoading[leader.id]) {
setBalanceLoading(prev => ({ ...prev, [leader.id]: true }))
try {
const balanceData = await apiService.leaders.balance({ leaderId: leader.id })
if (balanceData.data.code === 0 && balanceData.data.data) {
setBalanceMap(prev => ({
...prev,
[leader.id]: {
total: balanceData.data.data.totalBalance || '0',
available: balanceData.data.data.availableBalance || '0',
position: balanceData.data.data.positionBalance || '0'
}
}))
}
} catch (error) {
console.error(`获取 Leader ${leader.id} 余额失败:`, error)
setBalanceMap(prev => ({
...prev,
[leader.id]: { total: '-', available: '-', position: '-' }
}))
} finally {
setBalanceLoading(prev => ({ ...prev, [leader.id]: false }))
}
}
}
}
if (leaders.length > 0) {
loadBalances()
}
}, [leaders])
const handleDelete = async (leaderId: number) => {
try {
const response = await apiService.leaders.delete({ leaderId })
if (response.data.code === 0) {
message.success(t('leaderList.deleteSuccess') || '删除 Leader 成功')
message.success(t('leaderList.deleteSuccess'))
fetchLeaders()
} else {
message.error(response.data.msg || t('leaderList.deleteFailed') || '删除 Leader 失败')
message.error(response.data.msg || t('leaderList.deleteFailed'))
}
} catch (error: any) {
message.error(error.message || t('leaderList.deleteFailed') || '删除 Leader 失败')
message.error(error.message || t('leaderList.deleteFailed'))
}
}
const handleShowDetail = async (leader: Leader) => {
try {
setDetailModalVisible(true)
setDetailLeader(leader)
setDetailBalance(null)
setDetailBalanceLoading(false)
// 加载详情和余额
try {
const leaderDetail = await apiService.leaders.detail({ leaderId: leader.id })
if (leaderDetail.data.code === 0 && leaderDetail.data.data) {
setDetailLeader(leaderDetail.data.data)
}
// 加载余额
setDetailBalanceLoading(true)
try {
const balanceData = await apiService.leaders.balance({ leaderId: leader.id })
if (balanceData.data.code === 0 && balanceData.data.data) {
setDetailBalance(balanceData.data.data)
}
} catch (error) {
console.error('获取余额失败:', error)
setDetailBalance(null)
} finally {
setDetailBalanceLoading(false)
}
} catch (error: any) {
console.error('获取 Leader 详情失败:', error)
message.error(error.message || t('leaderList.fetchFailed'))
setDetailModalVisible(false)
setDetailLeader(null)
}
} catch (error: any) {
console.error('打开详情失败:', error)
message.error(error.message || t('leaderList.openDetailFailed'))
setDetailModalVisible(false)
setDetailLeader(null)
}
}
const handleRefreshDetailBalance = async () => {
if (!detailLeader) return
setDetailBalanceLoading(true)
try {
const balanceData = await apiService.leaders.balance({ leaderId: detailLeader.id })
if (balanceData.data.code === 0 && balanceData.data.data) {
setDetailBalance(balanceData.data.data)
message.success(t('leaderDetail.refresh'))
}
} catch (error: any) {
message.error(error.message || t('leaderDetail.fetchBalanceFailed'))
} finally {
setDetailBalanceLoading(false)
}
}
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleString(i18n.language || 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const getPositionColumns = () => {
return [
{
title: t('leaderDetail.market'),
dataIndex: 'title',
key: 'title',
render: (title: string) => {
if (!title) return <Text type="secondary">-</Text>
const displayText = isMobile && title.length > 20 ? `${title.slice(0, 20)}...` : title
return <Text style={{ fontSize: isMobile ? '12px' : '13px' }}>{displayText}</Text>
}
},
{
title: t('leaderDetail.side'),
dataIndex: 'side',
key: 'side',
render: (side: string) => {
const color = side === 'YES' ? 'green' : 'red'
return <Tag color={color}>{side}</Tag>
}
},
{
title: t('leaderDetail.quantity'),
dataIndex: 'quantity',
key: 'quantity',
render: (quantity: string) => formatUSDC(quantity)
},
{
title: t('leaderDetail.avgPrice'),
dataIndex: 'avgPrice',
key: 'avgPrice',
render: (price: string) => formatUSDC(price)
},
{
title: t('leaderDetail.currentValue'),
dataIndex: 'currentValue',
key: 'currentValue',
render: (value: string) => formatUSDC(value)
},
{
title: t('leaderDetail.pnl'),
dataIndex: 'pnl',
key: 'pnl',
render: (pnl: string | undefined) => {
if (!pnl || pnl === '0') {
return <Text type="secondary">-</Text>
} else {
const numPnl = parseFloat(pnl)
const color = numPnl > 0 ? '#52c41a' : '#ff4d4f'
return <Text style={{ color }}>{formatUSDC(pnl)}</Text>
}
}
}
]
}
const columns = [
{
title: t('leaderList.leaderName') || 'Leader 名称',
title: t('leaderList.leaderName'),
dataIndex: 'leaderName',
key: 'leaderName',
render: (text: string, record: Leader) => text || `Leader ${record.id}`
},
{
title: t('leaderList.walletAddress') || '钱包地址',
dataIndex: 'leaderAddress',
key: 'leaderAddress',
render: (address: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? '12px' : '14px' }}>
{isMobile ? `${address.slice(0, 6)}...${address.slice(-4)}` : address}
</span>
width: 150,
render: (text: string, record: Leader) => (
<Space direction="vertical" size={0}>
<Text strong style={{ fontSize: '14px' }}>{text || `Leader ${record.id}`}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>{record.leaderAddress}</Text>
</Space>
)
},
{
title: t('leaderList.remark') || '备注',
title: t('leaderList.remark'),
dataIndex: 'remark',
key: 'remark',
render: (remark: string | undefined) => remark ? (
<Text ellipsis={{ tooltip: remark }} style={{ maxWidth: isMobile ? 100 : 200 }}>
{remark}
</Text>
) : <Text type="secondary">-</Text>
},
{
title: t('leaderList.copyTradingCount') || '跟单关系数',
dataIndex: 'copyTradingCount',
key: 'copyTradingCount',
render: (count: number) => <Tag>{count}</Tag>
},
{
title: t('leaderList.createdAt') || '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleString(i18n.language || 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
width: 200,
ellipsis: true,
render: (remark: string | undefined) => {
if (!remark) return <Text type="secondary">-</Text>
return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 180 }}>{remark}</Text>
}
},
{
title: t('common.actions') || '操作',
title: t('leaderDetail.availableBalance'),
key: 'balance',
width: 150,
render: (_: any, record: Leader) => {
const balance = balanceMap[record.id]
if (!balance) return <Spin size="small" />
const displayText = balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`
return <Text style={{ color: '#1890ff', fontSize: '14px' }}>{displayText}</Text>
}
},
{
title: t('leaderList.copyTradingCount'),
dataIndex: 'copyTradingCount',
key: 'copyTradingCount',
width: 100,
render: (count: number) => <Tag color="cyan">{count}</Tag>
},
{
title: t('common.actions'),
key: 'action',
width: isMobile ? 150 : 200,
width: isMobile ? 180 : 250,
fixed: 'right' as const,
render: (_: any, record: Leader) => (
<Space size="small" wrap>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(record)}>
{t('common.viewDetail')}
</Button>
{record.website && (
<Button
type="link"
size="small"
icon={<GlobalOutlined />}
onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}
>
{t('leaderList.openWebsite') || '打开网页'}
<Button type="link" size="small" icon={<GlobalOutlined />} onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}>
{t('leaderList.openWebsite')}
</Button>
)}
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/leaders/edit?id=${record.id}`)}
>
{t('common.edit') || '编辑'}
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${record.id}`)}>
{t('common.edit')}
</Button>
<Popconfirm
title={t('leaderList.deleteConfirm') || '确定要删除这个 Leader 吗?'}
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) || `该 Leader 还有 ${record.copyTradingCount} 个跟单关系,请先删除跟单关系` : undefined}
title={t('leaderList.deleteConfirm')}
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) : undefined}
onConfirm={() => handleDelete(record.id)}
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
{t('common.delete') || '删除'}
{t('common.delete')}
</Button>
</Popconfirm>
</Space>
)
}
]
return (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
flexWrap: 'wrap',
gap: '12px'
}}>
<h2 style={{ margin: 0 }}>{t('leaderList.title') || 'Leader 管理'}</h2>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/leaders/add')}
size={isMobile ? 'middle' : 'large'}
>
{t('leaderList.addLeader') || '添加 Leader'}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('leaderList.title')}</h2>
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/leaders/add')} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}>
{t('leaderList.addLeader')}
</Button>
</div>
<Card>
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
{isMobile ? (
// 移动端卡片布局
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : leaders.length === 0 ? (
<Empty description={t('leaderList.noData') || '暂无 Leader 数据'} />
<Empty description={t('leaderList.noData')} />
) : (
<List
dataSource={leaders}
renderItem={(leader) => {
const date = new Date(leader.createdAt)
const formattedDate = date.toLocaleString(i18n.language || 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
const balance = balanceMap[leader.id]
return (
<Card
key={leader.id}
style={{
marginBottom: '12px',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
}}
bodyStyle={{ padding: '16px' }}
>
{/* Leader 名称和地址 */}
<Card key={leader.id} style={{ marginBottom: '16px', borderRadius: '12px', boxShadow: '0 2px 6px rgba(0,0,0,0.06)', border: '1px solid #f0f0f0' }} bodyStyle={{ padding: '16px' }}>
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
marginBottom: '8px',
color: '#1890ff'
}}>
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '6px', color: '#1890ff' }}>
{leader.leaderName || `Leader ${leader.id}`}
</div>
<div style={{
fontSize: '12px',
color: '#666',
fontFamily: 'monospace',
wordBreak: 'break-all'
}}>
<div style={{ fontSize: '12px', color: '#666', fontFamily: 'monospace', wordBreak: 'break-all' }}>
{leader.leaderAddress}
</div>
</div>
{/* 备注 */}
{leader.remark && (
<div style={{ marginBottom: '12px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>
{t('leaderList.remark') || '备注'}
</Text>
<Text style={{ fontSize: '12px', marginLeft: '4px' }}>
{leader.remark}
</Text>
{balance && (
<div style={{ marginBottom: '12px', padding: '12px', backgroundColor: '#f6ffed', borderRadius: '8px', border: '1px solid #b7eb8f' }}>
<div style={{ fontSize: '13px', color: '#52c41a', fontWeight: 'bold', marginBottom: '4px' }}>
{t('leaderDetail.availableBalance')}: {balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`}
</div>
<div style={{ fontSize: '11px', color: '#666' }}>
{t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
</div>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
{/* 跟单关系数 */}
<div style={{ marginBottom: '12px' }}>
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
<Tag color="cyan">{leader.copyTradingCount} {t('leaderList.copyTradingCount')}</Tag>
</div>
{/* 创建时间 */}
<div style={{ marginBottom: '12px', fontSize: '12px', color: '#999' }}>
{t('leaderList.createdAt') || '创建时间'}: {formattedDate}
</div>
{/* 操作按钮 */}
{leader.remark && (
<div style={{ marginBottom: '12px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>{t('leaderList.remark')}</Text>
<Text style={{ fontSize: '12px', marginLeft: '4px' }}>{leader.remark}</Text>
</div>
)}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Button type="primary" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(leader)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
{t('common.viewDetail')}
</Button>
{leader.website && (
<Button
type="link"
size="small"
icon={<GlobalOutlined />}
onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')}
style={{ flex: 1, minWidth: '80px' }}
>
{t('leaderList.openWebsite') || '打开网页'}
<Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
{t('leaderList.openWebsite')}
</Button>
)}
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/leaders/edit?id=${leader.id}`)}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.edit') || '编辑'}
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
{t('common.edit')}
</Button>
<Popconfirm
title={t('leaderList.deleteConfirm') || '确定要删除这个 Leader 吗?'}
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) || `该 Leader 还有 ${leader.copyTradingCount} 个跟单关系,请先删除跟单关系` : undefined}
title={t('leaderList.deleteConfirm')}
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) : undefined}
onConfirm={() => handleDelete(leader.id)}
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.delete') || '删除'}
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
{t('common.delete')}
</Button>
</Popconfirm>
</div>
@@ -284,22 +386,164 @@ const LeaderList: React.FC = () => {
)}
</div>
) : (
// 桌面端表格布局
<Table
dataSource={leaders}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
pageSize: 20,
showSizeChanger: true
}}
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `${total}` }}
size="large"
style={{ fontSize: '14px' }}
/>
)}
</Card>
{/* 详情 Modal */}
<Modal
title={
<Space>
<WalletOutlined />
<span>{t('leaderDetail.title')}</span>
</Space>
}
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailModalVisible(false)}>{t('common.close')}</Button>
]}
width={isMobile ? '95%' : 1000}
style={{ top: 20 }}
>
{!detailLeader ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : (
<>
{/* 基本信息 */}
<Descriptions
title={
<Space>
<WalletOutlined />
<span style={{ fontSize: '16px', fontWeight: 'bold' }}>{t('leaderDetail.basicInfo')}</span>
</Space>
}
bordered
column={isMobile ? 1 : 2}
size={isMobile ? 'small' : 'default'}
>
<Descriptions.Item label={t('leaderDetail.leaderName')}>
{detailLeader.leaderName || `Leader ${detailLeader.id}`}
</Descriptions.Item>
<Descriptions.Item label={t('leaderDetail.leaderAddress')}>
<span style={{ fontFamily: 'monospace' }}>{detailLeader.leaderAddress}</span>
</Descriptions.Item>
<Descriptions.Item label={t('leaderDetail.copyTradingCount')}>
<Tag color="cyan">{detailLeader.copyTradingCount || 0}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('leaderDetail.remark')}>
{detailLeader.remark || <Text type="secondary">-</Text>}
</Descriptions.Item>
<Descriptions.Item label={t('leaderDetail.updatedAt')}>
{formatTimestamp(detailLeader.updatedAt)}
</Descriptions.Item>
<Descriptions.Item label={t('leaderDetail.website')}>
{detailLeader.website ? (
<Button type="link" icon={<GlobalOutlined />} onClick={() => window.open(detailLeader.website, '_blank', 'noopener,noreferrer')} style={{ padding: 0 }}>
{t('leaderDetail.openWebsite')}
</Button>
) : <Text type="secondary">-</Text>}
</Descriptions.Item>
</Descriptions>
<Divider />
{/* 余额信息 */}
<div style={{ marginBottom: '16px' }}>
<Space>
<WalletOutlined />
<span style={{ fontSize: '16px', fontWeight: 'bold' }}>{t('leaderDetail.balanceInfo')}</span>
<Button type="text" size="small" icon={<ReloadOutlined />} onClick={handleRefreshDetailBalance} loading={detailBalanceLoading}>
{t('leaderDetail.refresh')}
</Button>
</Space>
</div>
{detailBalanceLoading && !detailBalance ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin />
</div>
) : detailBalance ? (
<>
<Row gutter={16} style={{ marginBottom: '16px' }}>
<Col xs={24} sm={8} md={6}>
<Card bordered={false} style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
<Statistic
title={t('leaderDetail.availableBalance')}
value={parseFloat(detailBalance.availableBalance)}
precision={4}
valueStyle={{ color: '#1890ff' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Card>
</Col>
<Col xs={24} sm={8} md={6}>
<Card bordered={false} style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
<Statistic
title={t('leaderDetail.positionBalance')}
value={parseFloat(detailBalance.positionBalance)}
precision={4}
valueStyle={{ color: '#722ed1' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Card>
</Col>
<Col xs={24} sm={8} md={6}>
<Card bordered={false} style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
<Statistic
title={t('leaderDetail.totalBalance')}
value={parseFloat(detailBalance.totalBalance)}
precision={4}
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Card>
</Col>
</Row>
{/* 持仓列表 */}
<Divider />
<div style={{ marginBottom: '16px' }}>
<Space>
<span style={{ fontSize: '16px', fontWeight: 'bold' }}>{t('leaderDetail.positions')}</span>
<Tag color="blue">{detailBalance.positions?.length || 0}</Tag>
</Space>
</div>
{detailBalance.positions && detailBalance.positions.length > 0 ? (
<Table
dataSource={detailBalance.positions}
columns={getPositionColumns()}
rowKey={(record, index) => `${record.title}-${record.side}-${index}`}
pagination={{ pageSize: 10, showSizeChanger: !isMobile }}
scroll={{ x: isMobile ? 800 : 'auto' }}
size={isMobile ? 'small' : 'middle'}
/>
) : (
<Empty description={t('leaderDetail.noPositions')} />
)}
</>
) : (
<Empty description={t('leaderDetail.noBalanceData')} />
)}
</>
)}
</Modal>
</div>
)
}
export default LeaderList
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'
import type { Dayjs } from 'dayjs'
import { apiService } from '../services/api'
import type { Statistics as StatisticsType } from '../types'
import { formatUSDC } from '../utils'
import { formatUSDC, formatNumber } from '../utils'
import { useMediaQuery } from 'react-responsive'
const { RangePicker } = DatePicker
@@ -17,17 +17,17 @@ const Statistics: React.FC = () => {
const [stats, setStats] = useState<StatisticsType | null>(null)
const [loading, setLoading] = useState(false)
const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null]>([null, null])
useEffect(() => {
fetchStatistics()
}, [])
const fetchStatistics = async () => {
setLoading(true)
try {
const startTime = dateRange[0] ? dateRange[0].valueOf() : undefined
const endTime = dateRange[1] ? dateRange[1].valueOf() : undefined
const response = await apiService.statistics.global({ startTime, endTime })
if (response.data.code === 0 && response.data.data) {
setStats(response.data.data)
@@ -40,11 +40,11 @@ const Statistics: React.FC = () => {
setLoading(false)
}
}
const handleDateRangeChange = (dates: [Dayjs | null, Dayjs | null] | null) => {
setDateRange(dates || [null, null])
}
const handleReset = () => {
setDateRange([null, null])
// 重置后自动刷新
@@ -52,7 +52,7 @@ const Statistics: React.FC = () => {
fetchStatistics()
}, 100)
}
return (
<div>
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '12px' }}>
@@ -85,13 +85,13 @@ const Statistics: React.FC = () => {
)}
</Space>
</div>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title={t('statistics.totalOrders') || '总订单数'}
value={stats?.totalOrders || 0}
value={formatNumber(stats?.totalOrders || 0)}
loading={loading}
/>
</Card>
+90 -10
View File
@@ -292,32 +292,38 @@ export const apiService = {
/**
* Leader
*/
add: (data: { leaderAddress: string; leaderName?: string; remark?: string; website?: string; category?: string }) =>
add: (data: { leaderAddress: string; leaderName?: string; remark?: string; website?: string; category?: string }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/add', data),
/**
* Leader
*/
update: (data: { leaderId: number; leaderName?: string; remark?: string; website?: string; category?: string }) =>
update: (data: { leaderId: number; leaderName?: string; remark?: string; website?: string; category?: string }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/update', data),
/**
* Leader
*/
delete: (data: { leaderId: number }) =>
delete: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/leaders/delete', data),
/**
* Leader
*/
list: (data: { category?: string } = {}) =>
list: (data: { category?: string } = {}) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/list', data),
/**
* Leader
*/
detail: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/detail', data)
detail: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/detail', data),
/**
* Leader
*/
balance: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/balance', data)
},
/**
@@ -720,3 +726,77 @@ 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)
}
+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
}
+47 -3
View File
@@ -79,6 +79,53 @@ export interface LeaderListResponse {
total: number
}
/**
*
*/
export interface PositionDto {
marketId: string
title: string // 市场名称
side: string // YES 或 NO
quantity: string
avgPrice: string
currentValue: string
pnl?: string
}
/**
* Account Leader
*/
export interface WalletBalanceResponse {
availableBalance: string // 可用余额(RPC 查询的 USDC 余额)
positionBalance: string // 仓位余额(持仓总价值)
totalBalance: string // 总余额 = 可用余额 + 仓位余额
positions?: PositionDto[]
}
/**
*
*/
export interface AccountBalanceResponse {
availableBalance: string // 可用余额(RPC 查询的 USDC 余额)
positionBalance: string // 仓位余额(持仓总价值)
totalBalance: string // 总余额 = 可用余额 + 仓位余额
positions?: PositionDto[]
}
/**
* Leader
*/
export interface LeaderBalanceResponse {
leaderId: number
leaderAddress: string
leaderName?: string
availableBalance: string // 可用余额(RPC 查询的 USDC 余额)
positionBalance: string // 仓位余额(持仓总价值)
totalBalance: string // 总余额 = 可用余额 + 仓位余额
positions?: PositionDto[]
}
/**
* Leader
*/
@@ -210,7 +257,6 @@ export interface CopyTrading {
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -260,7 +306,6 @@ export interface CopyTradingCreateRequest {
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -299,7 +344,6 @@ export interface CopyTradingUpdateRequest {
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
+35 -26
View File
@@ -1,61 +1,70 @@
/**
*
*
* @param value -
* @param maxDecimals -
* @returns ''
* @returns "123,456.78" ''
* @example
* formatNumber(1234567.89) => "1,234,567.89"
* formatNumber(1234567.00) => "1,234,567"
* formatNumber(1234.5678, 2) => "1,234.56"
* formatNumber(100.00) => "100"
* formatNumber(100.50) => "100.5"
* formatNumber(100.55) => "100.55"
*/
export const formatNumber = (value: string | number | undefined | null, maxDecimals?: number): string => {
if (value === undefined || value === null || value === '') {
return ''
}
const num = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(num)) {
return ''
}
// 如果有最大小数位数限制,先截断
// 处理小数位数
let numStr: string
if (maxDecimals !== undefined) {
const multiplier = Math.pow(10, maxDecimals)
const truncated = Math.floor(num * multiplier) / multiplier
return truncated.toFixed(maxDecimals).replace(/\.?0+$/, '')
numStr = truncated.toFixed(maxDecimals).replace(/\.?0+$/, '')
} else {
numStr = num.toString().replace(/\.?0+$/, '')
}
// 直接转换为字符串,然后去除尾随零
return num.toString().replace(/\.?0+$/, '')
// 分离整数和小数部分
const parts = numStr.split('.')
const integerPart = parts[0]
const decimalPart = parts[1]
// 为整数部分添加千分位分隔符
const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
// 组合结果
return decimalPart ? `${formattedInteger}.${decimalPart}` : formattedInteger
}
/**
* USDC
* USDC
* 4
* @param value -
* @returns '-'
* @returns "1,234.56" '-'
* @example
* formatUSDC(1234.56) => "1,234.56"
* formatUSDC(1234567.8901) => "1,234,567.8901"
* formatUSDC(1234.00) => "1,234"
* formatUSDC(1.23) => "1.23"
* formatUSDC(1.23456) => "1.2345"
* formatUSDC(1.2) => "1.2"
* formatUSDC(1) => "1"
*/
export const formatUSDC = (value: string | number | undefined | null): string => {
if (value === undefined || value === null || value === '') {
return '-'
}
const num = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(num)) {
return '-'
}
// 使用 Math.floor 截断到4位小数(不四舍五入)
const multiplier = Math.pow(10, 4)
const truncated = Math.floor(num * multiplier) / multiplier
// 使用 toFixed(4) 确保格式一致,然后去除尾随零和小数点
return truncated.toFixed(4).replace(/\.?0+$/, '')
return formatNumber(num, 4)
}
// 统一导出 ethers 相关工具函数
@@ -97,7 +106,7 @@ export const isAutoGeneratedOrderId = (orderId: string | undefined | null): bool
/**
* Polymarket URL
* moneyline moneyline
* moneyline
* moneyline,
* @param marketSlug - slug
* @param eventSlug - slug events[0].slug 使
* @param marketCategory - sports, crypto
@@ -114,7 +123,7 @@ export const getPolymarketUrl = (
): string | null => {
// 优先使用 eventSlug(跳转用的 slug
const slug = eventSlug || marketSlug
if (slug) {
// 如果是 moneyline 市场,跳转到 moneyline 页面
if (isMoneyline === true) {
@@ -123,12 +132,12 @@ export const getPolymarketUrl = (
// 其他市场跳转到普通市场页面
return `https://polymarket.com/event/${slug}`
}
// 如果没有 slug,使用 marketId(作为后备)
if (marketId && marketId.startsWith('0x')) {
return `https://polymarket.com/condition/${marketId}`
}
return null
}
+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",