Compare commits

..

10 Commits

Author SHA1 Message Date
WrBug 424153cfc7 docs: 添加 v1.1.8 release notes 2026-01-09 11:08:40 +08:00
WrBug 19508dcd19 feat: 实现 Polymarket Activity WebSocket 双重监听方案
主要功能:
- 新增 PolymarketActivityWsService:通过 Activity WebSocket 实时监听 Leader 交易(延迟 < 100ms)
- 实现双重监听机制:Activity WS(低延迟)+ On-Chain WS(高可靠性)
- 支持动态添加/移除 Leader 监听
- 地址筛选忽略大小写

修复和改进:
- 修复订阅消息格式:使用 'action' 而不是 'type'(与 @polymarket/real-time-data-client 保持一致)
- 修复 PING 消息格式:使用小写 'ping'(与官方客户端保持一致)
- 添加 outcomeIndex 字段支持:优先使用消息中的 outcomeIndex,fallback 到从 outcome 解析
- 添加 connection_id 字段到 DTO
- 改进日志记录:增加 INFO 级别日志以便调试

代码重构:
- JsonUtils 重构:将 fromJson 和 toJson 改为扩展方法
- 更新后端开发规范:JSON 解析和 Data Class 规范

前端修复:
- 修复 InputNumber parser 类型错误:返回 number 而不是 string

文档:
- 添加双重监听方案文档
- 添加 Polymarket Activity WebSocket API 格式文档
2026-01-09 10:41:15 +08:00
WrBug a16b6fce54 feat: 支持同一用户创建多个相同 leader 的跟单配置
- 删除 copy_trading 表的唯一约束 uk_account_leader
- 移除实体类 CopyTrading 的唯一约束注解
- 移除创建跟单时的重复检查逻辑
- 修改 Repository 方法 findByAccountIdAndLeaderId 返回列表而非单个对象
- 更新查询逻辑以支持返回多个跟单配置

现在允许同一用户对同一个 leader 创建多个不同配置的跟单关系。
2026-01-09 08:56:14 +08:00
WrBug 0327eaffe3 feat: 添加市场截止时间筛选功能
后端:
- 为 copy_trading 表添加 max_market_end_date 字段(毫秒时间戳)
- 为 markets 表添加 end_date 字段,迁移时清空已有数据
- 实现市场截止时间过滤逻辑(仅跟单截止时间小于设置时间的订单)
- 更新 CopyTradingService 支持 maxMarketEndDate 的创建和更新
- 更新 FilterResult 添加 FAILED_MARKET_END_DATE 状态
- 更新 CopyOrderTrackingService 传递 marketEndDate 参数

前端:
- 添加市场截止时间筛选 UI(支持小时和天两种单位)
- 输入限制:仅支持整数输入(precision=0)
- 更新 AddModal 和 EditModal 支持市场截止时间配置
- 更新过滤类型显示映射(MARKET_END_DATE)
- 添加多语言支持(中文、英文、繁体中文)

数据库迁移:
- V22: 合并添加 max_market_end_date 和 end_date 字段
- 迁移时清空 markets 表数据,确保所有数据都包含 end_date
2026-01-09 08:35:05 +08:00
WrBug b13aef944c refactor: 统一使用 MarketService 并改用 LRU 缓存
主要改进:
1. 将 MarketService 缓存从 ConcurrentHashMap 改为 Caffeine LRU 缓存
   - 最多缓存 200 条市场记录
   - 自动淘汰最近最少使用的记录
   - 提高缓存命中率和性能

2. 添加 Caffeine 依赖 (com.github.ben-manes.caffeine:caffeine:3.1.8)

3. 重构以下服务,统一使用 MarketService 获取市场信息:
   - OrderPushService: 删除 fetchMarketInfo() 方法,直接使用 MarketService
   - CopyOrderTrackingService: 移除直接调用 Gamma API 的代码
   - AccountService: 订单通知中使用 MarketService
   - OrderStatusUpdateService: 买入/卖出订单通知使用 MarketService

优势:
- 减少重复的 API 调用,利用三级缓存(内存 → 数据库 → API)
- 提高代码一致性和可维护性
- 统一市场数据获取逻辑,便于后续扩展
2026-01-08 18:33:49 +08:00
WrBug 9ed5190bfe feat: 添加关键字过滤功能并优化市场 slug 处理
主要变更:
1. 关键字过滤功能
   - 添加关键字过滤模式(白名单/黑名单/禁用)
   - 支持关键字列表配置(JSON 存储)
   - 实现关键字匹配逻辑(不区分大小写)
   - 数据库迁移:V20__add_keyword_filter.sql

2. 市场 slug 优化
   - 区分显示用 slug 和跳转用 eventSlug
   - 添加 event_slug 字段到 markets 表(V21__add_event_slug_to_markets.sql)
   - 从 events[0].slug 获取跳转用的 slug 并存入数据库
   - 减少 API 请求,提高性能

3. UI 优化
   - 将创建/编辑跟单配置改为 Modal 方式
   - 删除独立的 CopyTradingAdd 和 CopyTradingEdit 页面
   - 更新前端跳转逻辑,优先使用 eventSlug

4. API 响应优化
   - 更新 MarketResponse 添加 events 字段
   - 统一从 events[0].slug 获取跳转用的 slug
   - 更新所有相关服务使用新的 slug 获取逻辑
2026-01-08 17:47:00 +08:00
WrBug 3d5a923caf feat: 订单列表按市场分组功能重构
- 后端实现按市场分组的订单列表接口
  - 添加 MarketGroupedOrdersRequest、MarketOrderStats、MarketOrderGroup、MarketGroupedOrdersResponse DTO
  - 实现 getBuyOrderListGroupedByMarket 和 getSellOrderListGroupedByMarket 方法
  - 添加 POST /api/copy-trading/orders/grouped-by-market 接口
  - 支持分页和统计信息(订单数、总金额、盈亏、成交状态等)

- 前端重构:移除前端分组逻辑,改为调用后端分组接口
  - BuyOrdersTab 和 SellOrdersTab 使用后端分组接口
  - 移除前端 useMemo 分组计算逻辑
  - 添加分组数据状态管理和分页支持

- 分组默认折叠状态
  - 移除桌面端自动展开逻辑
  - 所有设备默认折叠,用户手动展开

- 修复 moneyline URL 逻辑
  - getPolymarketUrl 函数添加 isMoneyline 参数
  - 暂时不自动跳转到 moneyline 页面(需要后端提供标识)

- 多语言支持
  - 添加 markets 翻译键(中文、繁体中文、英文)
2026-01-08 13:19:52 +08:00
WrBug 2af2c0e86a feat: 订单列表按市场分组并支持跳转到Polymarket
- 后端添加市场slug和分类字段
  - 在 BuyOrderInfo、SellOrderInfo、MatchedOrderInfo DTO 中添加 marketSlug 和 marketCategory 字段
  - 在 CopyTradingStatisticsService 中填充这些字段(从 Market 实体获取)

- 前端实现按市场分组功能
  - 添加分组视图切换开关(列表视图/分组视图)
  - 分组显示:按市场分组,显示市场名称、订单数、总金额、盈亏等统计信息
  - 买入订单分组显示是否完全成交状态(全部成交/部分成交)
  - 卖出订单分组显示总盈亏
  - 支持展开/折叠功能(单个和全部)
  - 桌面端默认全部展开,移动端默认全部折叠

- 市场名称点击跳转功能
  - 添加 getPolymarketUrl 工具函数,根据市场分类构建URL
  - sports 分类市场跳转到 /moneyline 页面(spread 市场)
  - 其他分类市场跳转到普通市场页面
  - 如果没有 slug,使用 marketId 作为后备(跳转到 condition 页面)
  - 分组视图、列表视图、移动端卡片视图均支持点击跳转

- 多语言支持
  - 添加分组相关翻译键:groupByMarket、expandAll、collapseAll、allFullyMatched、partiallyMatched、orderCount、totalAmount、totalPnl、statusBreakdown
2026-01-08 12:39:09 +08:00
WrBug d376a82ccc feat: 添加市场信息管理和订单ID复制功能
- 新增市场信息表和服务,支持市场名称缓存和自动更新
  - 创建 Market 实体和 MarketRepository
  - 实现 MarketService 提供市场信息查询和缓存
  - 实现 MarketPollingService 每30秒自动检查并更新缺失的市场信息
  - 添加数据库迁移 V19 创建 markets 表

- 订单列表显示市场名称
  - 在 BuyOrderInfo、SellOrderInfo、MatchedOrderInfo 中添加 marketTitle 字段
  - 订单查询时自动查询并填充市场名称
  - 前端订单列表显示市场名称(优先显示名称,ID作为辅助信息)

- 前端订单ID复制功能
  - 添加 isAutoGeneratedOrderId 和 copyToClipboard 工具函数
  - 非自动生成的订单ID支持一键复制
  - 买入、卖出、匹配订单列表均支持复制功能

- 仓位检查延迟检测机制优化
  - 首次检测到仓位不存在时先记录,3分钟后再次检查
  - 避免因API延迟导致的误判
2026-01-08 11:56:52 +08:00
WrBug e75c93ddf4 Merge pull request #16 from WrBug/release/v1.1.7
Release v1.1.7
2026-01-07 12:53:27 +08:00
62 changed files with 5276 additions and 1965 deletions
+171 -594
View File
@@ -5,391 +5,62 @@ path: backend/**
# 后端开发规范
## 代码完成规范
### TODO 处理规则
- **禁止**在代码中添加 TODO 注释
- **必须**根据 TODO 的内容直接完成代码实现
- 如果遇到暂时无法完全实现的功能,应该:
1. 实现一个可用的基础版本
2. 添加清晰的注释说明当前实现的限制和后续改进方向
3. 确保代码可以正常编译和运行
- **禁止**使用 `// TODO: 实现XXX` 这样的注释
- **禁止**使用 `// FIXME:` 或 `// XXX:` 这样的注释
- 如果某个功能需要依赖外部资源(如 API、库等),应该:
1. 先实现一个占位或模拟实现
2. 在注释中说明依赖关系和实现方式
3. 确保代码逻辑完整,不会因为未实现的功能而崩溃
### API 调用实现规则
- **必须**查找相关的 API 文档或接口定义
- **必须**根据 API 文档完成代码实现
- **禁止**在 API 调用处添加 TODO 注释
- **禁止**直接返回 mock 数据或硬编码的假数据
- **必须**调用真实的 API 或查询真实的数据库
- 如果 API 文档不完整,应该:
1. 查找项目中已有的类似 API 调用作为参考
2. 查看 API 接口定义(如 `PolymarketClobApi.kt`
3. 查看 API 文档(如 `docs/polymarket-api-reference.md`
4. 实现一个可用的版本,包含错误处理
- 如果 API 调用失败,应该:
1. 返回明确的错误信息
2. 记录错误日志
3. 返回错误响应,而不是返回 mock 数据
### 代码完成示例
```kotlin
// ❌ 错误:添加 TODO 注释
fun getAccountBalance(accountId: Long?): Result<AccountBalanceResponse> {
// TODO: 调用 Polymarket API 查询余额
return Result.success(AccountBalanceResponse(usdcBalance = "0"))
}
// ✅ 正确:查找 API 文档并完成实现
// 1. 查找 API 接口定义:PolymarketClobApi.getActiveOrders()
// 2. 查找 API 文档:docs/polymarket-api-reference.md
// 3. 实现完整的 API 调用逻辑,调用真实的 API
fun getAccountBalance(accountId: Long?): Result<AccountBalanceResponse> {
return try {
val account = getAccount(accountId) ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 如果账户没有配置 API 凭证,返回错误而不是 mock 数据
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
return Result.failure(IllegalStateException("账户未配置 API 凭证,无法查询余额"))
}
// 解密 API 凭证并创建认证客户端
val apiKey = cryptoUtils.decrypt(account.apiKey!!)
val apiSecret = cryptoUtils.decrypt(account.apiSecret!!)
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase!!)
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
// 调用真实的 API 查询余额
val response = clobApi.getActiveOrders(limit = 100)
if (response.isSuccessful && response.body() != null) {
// 根据 API 响应处理余额数据(从真实响应中解析)
val orders = response.body()!!
// 实际应该调用余额查询接口或从链上查询
// 这里只是示例,实际应该调用真实的余额查询 API
val balance = queryRealBalanceFromApi(clobApi, account.walletAddress)
Result.success(AccountBalanceResponse(usdcBalance = balance))
} else {
logger.error("查询余额失败: ${response.code()} ${response.message()}")
Result.failure(Exception("查询余额失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("查询账户余额失败", e)
Result.failure(e)
}
}
```
## 需求文档引用
### 跟单系统需求
- **需求文档**: `docs/copy-trading-requirements.md`
- **说明**: 所有跟单系统相关的功能实现必须严格按照需求文档执行
- **核心功能**:
- 账户管理(通过私钥导入,支持多账户)
- Leader 管理(被跟单者管理)
- 订单同步与执行(监控 Leader 交易并自动复制)
- 跟单配置管理(全局配置和单个 Leader 配置)
- 风险控制(每日亏损限制、订单数限制等)
- 跟单记录与统计
**重要提示**: 在实现跟单系统相关功能时,请先查阅 `docs/copy-trading-requirements.md` 了解详细需求,包括:
- 数据模型设计(Account、Leader、CopyOrder 等)
- API 接口设计(请求/响应格式)
- 业务规则和验证逻辑
- 安全要求(私钥加密存储、API Key 管理等)
## 核心原则
- **禁止**在代码中添加 TODO/FIXME/XXX 注释
- **禁止**返回 mock 数据或硬编码的假数据
- **禁止**在 API 调用失败时返回默认值作为 fallback
- 所有功能必须完整实现,确保代码可以正常运行
## 项目范围
### 平台支持
- **仅支持**: Polymarket 平台
- **不支持**: 其他预测市场平台(如 Kalshi 等)
### 分类支持
- **仅支持**:
- `sports`: 体育相关市场
- `crypto`: 加密货币相关市场
- **不支持**: 其他分类(如 politics、entertainment 等)
### 分类验证
- 所有涉及分类的接口、实体、服务必须验证分类参数
- 分类参数只能是 `sports` 或 `crypto`
- 无效分类应返回明确的错误提示
## 包名规范
- **平台**: 仅支持 Polymarket
- **包名**: `com.wrbug.polymarketbot`
- 所有代码必须使用此包名
## 实体类规范
### ID字段规范
- **必须**使用 `Long? = null` 作为 `@GeneratedValue` 的 id 字段
- **禁止**使用 `Long = 0` 或其他默认值
```kotlin
// ✅ 正确
@Entity
@Table(name = "example_table")
data class ExampleEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
// ...
)
// ❌ 错误
@Entity
data class ExampleEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0, // 禁止使用
// ...
val id: Long? = null, // ✅ 正确:使用可空类型
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(), // ✅ 使用 Long 时间戳
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
val amount: BigDecimal = BigDecimal.ZERO // ✅ 使用 BigDecimal
)
```
**规则**:
- ID 字段必须使用 `Long? = null`
- 时间字段使用 `Long` 时间戳(毫秒)
- 数值字段使用 `BigDecimal`
- **禁止**使用 `LocalDateTime` 或 `Double`
## 配置文件规范
### 配置文件格式
- **必须**使用 `application.properties` 格式
- **禁止**使用 `application.yml` 格式
- 配置文件位置: `src/main/resources/application.properties`
### 配置示例
```properties
# 应用配置
spring.application.name=polymarket-bot-backend
# 数据源配置
spring.datasource.url=jdbc:mysql://localhost:3306/polymarket_bot?useSSL=false&serverTimezone=UTC&characterEncoding=utf8mb4
spring.datasource.username=${DB_USERNAME:root}
spring.datasource.password=${DB_PASSWORD:password}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# HikariCP 连接池配置
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=30000
# JPA 配置
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
# Flyway 配置
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true
# 服务器配置
server.port=${SERVER_PORT:8000}
# 日志配置
logging.level.root=INFO
logging.level.com.wrbug.polymarketbot=DEBUG
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
```
### 环境变量引用
- 使用 `${ENV_VAR:default}` 格式引用环境变量
- 支持多环境配置: `application-dev.properties`, `application-prod.properties`
- **必须**使用 `application.properties` 格式(禁止 `application.yml`
- 使用 `${ENV_VAR:default}` 引用环境变量
## 代码规范
### Controller规范
- Controller 方法**禁止**使用 `suspend`
- 如需调用 suspend 方法,使用 `runBlocking`(最小化使用)
- 只作用于 suspend 方法调用
### Controller
- **禁止**使用 `suspend`
- 使用 `runBlocking` 调用 suspend 方法
- 统一使用 `@PostMapping`
```kotlin
// ✅ 正确
@RestController
class ExampleController(
private val exampleService: ExampleService
) {
@PostMapping("/example")
fun getExample(): ResponseEntity<ApiResponse<ExampleDto>> {
val data = runBlocking { exampleService.getData() }
return ResponseEntity.ok(ApiResponse.success(data))
}
}
// ❌ 错误
@RestController
class ExampleController {
@PostMapping("/example")
suspend fun getExample(): ResponseEntity<ApiResponse<ExampleDto>> { // 禁止使用suspend
// ...
}
}
```
### Service规范
- Service 层可以使用 `suspend` 方法
### Service
- 可以使用 `suspend` 方法
- 使用 `@Transactional` 管理事务
- 使用构造函数注入依赖
```kotlin
@Service
class ExampleService(
private val exampleRepository: ExampleRepository
) {
suspend fun getAllData(): List<ExampleEntity> {
return exampleRepository.findAll()
}
@Transactional
fun saveData(entity: ExampleEntity): ExampleEntity {
return exampleRepository.save(entity)
}
}
```
### Repository规范
- Repository 接口继承 `JpaRepository`
### Repository
- 继承 `JpaRepository<Entity, Long>`
- 使用 Spring Data JPA 方法命名规范
```kotlin
@Repository
interface ExampleRepository : JpaRepository<ExampleEntity, Long> {
fun findByCode(code: String): ExampleEntity?
fun findByCategoryAndStatus(category: String, status: String): List<ExampleEntity>
fun findByCategory(category: String): List<ExampleEntity> // category: sports 或 crypto
}
```
## API 接口规范
### Entity规范
- 使用 `@Entity` 和 `@Table` 注解
- ID字段使用 `Long? = null`
- 时间字段使用 `Long` 时间戳(毫秒)
- 数值字段使用 `BigDecimal`,使用 `String` 存储
```kotlin
@Entity
@Table(name = "example_table")
data class ExampleEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "code", unique = true, nullable = false, length = 100)
val code: String = "",
@Column(name = "category", nullable = false, length = 20)
val category: String = "", // sports 或 crypto
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
val amount: BigDecimal = BigDecimal.ZERO,
@Column(name = "status", nullable = false, length = 20)
val status: String = "", // active, inactive
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
```
## 数值计算规范
### BigDecimal使用
- 所有数值计算必须使用 `BigDecimal`
- 使用 `String` 存储数值
- 使用扩展函数进行安全转换和比较
```kotlin
// 使用扩展函数
val amount = "0.5".toSafeBigDecimal()
val total = amount.add("0.4".toSafeBigDecimal())
// 比较
if (total.lt(BigDecimal.ONE)) {
// 业务逻辑
}
```
## 时间字段规范
### 时间戳使用
- 所有时间字段使用 `Long` 类型存储毫秒级时间戳
- **禁止**使用 `LocalDateTime` 或其他时间类型
```kotlin
// ✅ 正确
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
// ❌ 错误
@Column(name = "created_at")
val createdAt: LocalDateTime = LocalDateTime.now() // 禁止使用
```
## HTTP客户端规范
### Retrofit使用
- 使用 Retrofit 定义 API 接口
- 使用 OkHttp 作为底层 HTTP 客户端
- 使用拦截器处理认证
```kotlin
// Polymarket CLOB API接口定义(跟单系统需要)
interface PolymarketClobApi {
@POST("/orders")
suspend fun createOrder(@Body order: CreateOrderRequest): Response<OrderResponse>
@GET("/orders/active")
suspend fun getActiveOrders(
@Query("market") market: String?,
@Query("limit") limit: Int?,
@Query("offset") offset: Int?
): Response<List<OrderResponse>>
@DELETE("/orders/{orderId}")
suspend fun cancelOrder(@Path("orderId") orderId: String): Response<CancelOrderResponse>
@GET("/trades")
suspend fun getTrades(
@Query("market") market: String?,
@Query("user") user: String?,
@Query("limit") limit: Int?,
@Query("offset") offset: Int?
): Response<List<TradeResponse>>
}
// Retrofit配置
@Configuration
class RetrofitConfig {
@Bean
fun polymarketClobApi(): PolymarketClobApi {
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor())
.build()
return Retrofit.Builder()
.baseUrl("https://clob.polymarket.com")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(PolymarketClobApi::class.java)
}
}
```
## API接口规范
### 请求规范
- **所有接口统一使用POST方法**,包括查询类接口
- 请求头: `Content-Type: application/json`
- 请求体: JSON格式
### 响应规范
- **统一响应格式**:
### 统一响应格式
```json
{
"code": 0,
@@ -398,77 +69,7 @@ class RetrofitConfig {
}
```
- **响应字段说明**:
- `code`: 响应码,0表示成功,非0表示失败
- `data`: 响应数据,可以是任意类型(对象、数组、字符串、数字等)
- `msg`: 响应消息,成功时通常为空,失败时包含错误提示
### 响应示例
**成功响应**:
```json
{
"code": 0,
"data": {
"id": "123",
"name": "example"
},
"msg": ""
}
```
**失败响应**:
```json
{
"code": 1001,
"data": null,
"msg": "参数错误:参数不能为空"
}
```
### Controller实现示例
```kotlin
@RestController
@RequestMapping("/api/example")
class ExampleController(
private val exampleService: ExampleService
) {
private val logger = LoggerFactory.getLogger(ExampleController::class.java)
@PostMapping("/list")
fun getList(@RequestBody request: ExampleListRequest): ResponseEntity<ApiResponse<ExampleListResponse>> {
return try {
val data = runBlocking { exampleService.getList(request) }
val response = ExampleListResponse(
list = data,
total = data.size.toLong(),
page = request.page ?: 1,
limit = request.limit ?: 20
)
ResponseEntity.ok(ApiResponse.success(response))
} catch (e: Exception) {
logger.error("Failed to get list", e)
ResponseEntity.ok(ApiResponse.serverError("获取列表失败:${e.message}"))
}
}
}
// 统一响应格式
data class ApiResponse<T>(
val code: Int,
val data: T?,
val msg: String
) {
companion object {
fun <T> success(data: T): ApiResponse<T> = ApiResponse(0, data, "")
fun <T> paramError(msg: String): ApiResponse<T> = ApiResponse(1001, null, msg)
fun <T> serverError(msg: String): ApiResponse<T> = ApiResponse(5001, null, msg)
}
}
```
### 错误码规范
### 错误码范围
- `0`: 成功
- `1001-1999`: 参数错误
- `2001-2999`: 认证/权限错误
@@ -476,7 +77,131 @@ data class ApiResponse<T>(
- `4001-4999`: 业务逻辑错误
- `5001-5999`: 服务器内部错误
详细错误码定义参见需求文档
### Controller 示例
```kotlin
@RestController
@RequestMapping("/api/example")
class ExampleController(
private val exampleService: ExampleService,
private val messageSource: MessageSource
) {
@PostMapping("/list")
fun getList(@RequestBody request: ExampleListRequest): ResponseEntity<ApiResponse<ExampleListResponse>> {
return try {
val data = runBlocking { exampleService.getList(request) }
ResponseEntity.ok(ApiResponse.success(data))
} catch (e: Exception) {
logger.error("Failed to get list", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource))
}
}
}
```
## 数值计算
```kotlin
val amount = "0.5".toSafeBigDecimal()
val total = amount.add("0.4".toSafeBigDecimal())
if (total.lt(BigDecimal.ONE)) {
// 业务逻辑
}
```
## JSON 解析规范
### 使用扩展函数(推荐)
- **优先**使用扩展函数 `fromJson<T>()` 和 `toJson()`,禁止直接使用 `Gson`
- 扩展函数提供了类型安全的解析,使用方便
```kotlin
// ✅ 正确:使用扩展函数(推荐)
val json = "{\"name\":\"test\"}"
val obj = json.fromJson<MyDataClass>()
val jsonStr = obj.toJson()
// ✅ 也可以:使用 JsonUtils 类(兼容旧代码)
val obj = jsonUtils.fromJson<MyDataClass>(json)
// ❌ 错误:直接使用 Gson
val obj = gson.fromJson(json, MyDataClass::class.java)
```
### 扩展函数列表
- `String?.fromJson<T>()` - 解析 JSON 字符串为对象(支持泛型)
- `JsonElement?.fromJson<T>()` - 解析 JsonElement 为对象(支持泛型)
- `Any?.toJson()` - 将对象转换为 JSON 字符串
- `String?.parseStringArray()` - 解析 JSON 字符串数组
### JsonUtils 类
- `JsonUtils` 主要用于初始化全局 Gson 实例,供扩展函数使用
- 保留 `parseStringArray()` 方法用于兼容旧代码
- 不推荐直接使用 `JsonUtils` 的方法,优先使用扩展函数
### Data Class 规范
- **所有 data class 字段必须提供默认值**
- 可空字段使用 `? = null`
- 非空字段提供合适的默认值(空字符串、空集合、默认数值等)
```kotlin
// ✅ 正确:所有字段都有默认值
data class MyDto(
val name: String = "",
val age: Int = 0,
val tags: List<String> = emptyList(),
val optional: String? = null
)
// ❌ 错误:缺少默认值
data class MyDto(
val name: String, // 缺少默认值
val age: Int = 0
)
```
## Side 判断规范
**禁止**使用 "YES"/"NO" 字符串判断 side
```kotlin
// ❌ 错误
if (side != null && side.uppercase() == "NO") { }
// ✅ 正确
if (outcomeIndex != null && outcomeIndex == 1) { }
```
## 多语言规范
- **禁止**硬编码中文或英文错误消息
- **必须**使用 `ErrorCode` 枚举和 `MessageSource`
```kotlin
// ❌ 错误
return ResponseEntity.ok(ApiResponse.paramError("配置ID不能为空"))
// ✅ 正确
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, messageSource))
```
### 多语言支持范围
1. **API 响应消息**: 使用 `ErrorCode` + `MessageSource`
2. **日志消息**: 可使用中文或英文
3. **代码注释**: 建议使用中文
4. **数据库字段**: 使用英文(snake_case
## HTTP 客户端
- 使用 Retrofit + OkHttp
- 使用拦截器处理认证
## 跟单系统需求
参考文档: `docs/copy-trading-requirements.md`
核心功能:
- 账户管理(私钥导入,多账户)
- Leader 管理
- 订单同步与执行
- 跟单配置管理
- 风险控制
- 跟单记录与统计
## 禁止事项
@@ -484,173 +209,25 @@ data class ApiResponse<T>(
- ❌ 禁止使用 `!!` 除非有明确原因
- ❌ 禁止忽略异常
- ❌ 禁止硬编码配置值
- ❌ 禁止提交敏感信息到Git
- ❌ Controller 方法禁止使用 `suspend`
- ❌ 实体类ID禁止使用 `Long = 0`
- ❌ **禁止直接返回 mock 数据或硬编码的假数据**
- ❌ **禁止在 API 调用失败时返回 mock 数据作为默认值**
- ❌ **所有返回的数据必须来自真实的 API 调用或数据库查询**
### 配置
- ❌ 禁止使用 `application.yml`
- ❌ 禁止在代码中硬编码配置值
- ❌ 禁止提交敏感信息到 Git
### 类型
- ❌ 禁止使用 `Double` 进行数值计算
- ❌ 禁止使用 `LocalDateTime` 存储时间
- ❌ 禁止实体类ID使用非空默认值
- ❌ 禁止实体类 ID 使用非空默认值
- ❌ 禁止直接使用 `Gson`(必须使用 `JsonUtils`
- ❌ 禁止 data class 字段缺少默认值
### API接口
- ❌ 禁止使用GETPUTDELETE等方法(统一使用POST
### API 接口
- ❌ 禁止使用 GET/PUT/DELETE(统一使用 POST
- ❌ 禁止返回不符合统一格式的响应
- ❌ 禁止在响应中直接返回Map类型(使用data class
- ❌ 禁止在响应中直接返回 Map 类型
### Side 判断规范
- ❌ **禁止使用 "YES""NO" 字符串判断 side**
- ✅ **必须使用 `outcomeIndex` 判断方向**0 = 第一个 outcome1 = 第二个 outcome,以此类推)
- ✅ 如果必须使用 side 字符串,应该从市场的 outcomes 数组中获取,而不是硬编码 "YES"/"NO"
- ✅ 对于二元市场的价格转换,应该通过 `outcomeIndex` 判断是否为第二个 outcomeindex = 1),而不是判断 side 是否为 "NO"
### Side 判断
- ❌ 禁止使用 "YES"/"NO" 字符串判断 side
- ✅ 必须使用 `outcomeIndex` 判断
```kotlin
//错误:使用字符串比较判断 side
if (side != null && side.uppercase() == "NO") {
// 转换价格
}
// ❌ 错误:硬编码 "YES"/"NO" 判断
when (side.uppercase()) {
"YES" -> // ...
"NO" -> // ...
}
// ✅ 正确:使用 outcomeIndex 判断
if (outcomeIndex != null && outcomeIndex == 1) {
// 第二个 outcome(在二元市场中通常是 NO),转换价格
}
// ✅ 正确:从市场 outcomes 获取 side 信息
val outcomes = JsonUtils.parseStringArray(market.outcomes)
val targetOutcomeIndex = outcomes.indexOfFirst { it.equals(side, ignoreCase = true) }
if (targetOutcomeIndex >= 0) {
// 使用 targetOutcomeIndex 进行判断
}
```
## 多语言使用规范
### 错误消息和响应文本
- **禁止**在代码中硬编码中文或英文错误消息
- **必须**使用 `ErrorCode` 枚举定义错误码和消息
- **必须**使用 `ApiResponse.error(ErrorCode, messageSource)` 或 `MessageUtils.getMessage()` 获取国际化消息
- **禁止**直接使用 `ApiResponse.paramError("硬编码消息")` 或 `ApiResponse.serverError("硬编码消息")`
- 错误消息的默认语言使用中文(在 `ErrorCode` 枚举中定义),通过 `MessageSource` 支持多语言
### 使用 ErrorCode 和 MessageSource
项目已经实现了国际化支持,必须使用以下方式:
```kotlin
// ❌ 错误:硬编码错误消息
return ResponseEntity.ok(ApiResponse.paramError("配置ID不能为空"))
return ResponseEntity.ok(ApiResponse.serverError("获取配置列表失败:${e.message}"))
// ✅ 正确:使用 ErrorCode 枚举
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, messageSource = messageSource))
// ✅ 正确:使用 ErrorCode 和自定义消息(如果需要动态消息)
return ResponseEntity.ok(ApiResponse.error(
ErrorCode.PARAM_ERROR,
customMsg = "配置ID不能为空",
messageSource = messageSource
))
// ✅ 正确:使用 MessageUtils
@Autowired
private lateinit var messageUtils: MessageUtils
return ResponseEntity.ok(ApiResponse.error(
ErrorCode.PARAM_EMPTY,
messageSource = messageSource
))
```
### 添加新的错误码
如果需要添加新的错误码,必须在 `ErrorCode` 枚举中定义:
```kotlin
enum class ErrorCode(
val code: Int,
val message: String, // 默认消息(中文)
val messageKey: String // 国际化消息键
) {
// 新错误码示例
NOTIFICATION_CONFIG_NOT_FOUND(3009, "通知配置不存在", "error.notification_config_not_found"),
NOTIFICATION_CONFIG_INVALID(4009, "通知配置无效", "error.notification_config_invalid"),
}
```
然后在语言资源文件中添加对应的翻译:
- `src/main/resources/messages_zh_CN.properties`
- `src/main/resources/messages_zh_TW.properties`
- `src/main/resources/messages_en.properties`
### 日志消息规范
- **日志消息可以使用中文或英文**,便于开发调试
- **禁止**在日志中硬编码用户可见的错误消息(应该使用 ErrorCode)
- 日志消息应该清晰、简洁,便于排查问题
- 日志中的业务数据(如账户名、订单ID等)可以使用原始值
### 代码注释规范
- **代码注释可以使用中文或英文**
- **业务逻辑注释**建议使用中文,便于团队理解
- **API 文档注释**(KDoc)建议使用中文,但也可以使用英文
- **类和方法注释**应该清晰说明功能和参数
### 数据库字段和配置
- **数据库字段名**使用英文(snake_case
- **配置项名称**使用英文(kebab-case 或 dot.notation
- **枚举值**使用英文(UPPER_SNAKE_CASE
- **常量定义**使用英文(UPPER_SNAKE_CASE
### 多语言支持策略
1. **API 响应消息**:使用 `ErrorCode` 枚举,通过 `MessageSource` 支持多语言
2. **错误码**:使用 `ErrorCode` 枚举,包含 `code`、`message`(默认中文)和 `messageKey`(国际化键)
3. **日志消息**:可以使用中文或英文,便于开发调试
4. **代码注释**:可以使用中文或英文,建议使用中文
5. **业务数据**:根据实际需求,可以包含多语言内容(如市场标题、描述等)
### Controller 中使用多语言示例
```kotlin
@RestController
class NotificationController(
private val notificationConfigService: NotificationConfigService,
private val messageSource: MessageSource // 注入 MessageSource
) {
@PostMapping("/configs/list")
fun list(@RequestBody request: NotificationConfigListRequest): ResponseEntity<ApiResponse<List<NotificationConfigDto>>> {
return try {
// ... 业务逻辑
ResponseEntity.ok(ApiResponse.success(configs))
} catch (e: Exception) {
logger.error("获取配置列表失败: ${e.message}", e)
// ✅ 正确:使用 ErrorCode 和 MessageSource
ResponseEntity.ok(ApiResponse.error(
ErrorCode.SERVER_ERROR,
messageSource = messageSource
))
}
}
@PostMapping("/configs/detail")
fun detail(@RequestBody request: NotificationConfigDetailRequest): ResponseEntity<ApiResponse<NotificationConfigDto>> {
if (request.id == null) {
// ✅ 正确:使用 ErrorCode
return ResponseEntity.ok(ApiResponse.error(
ErrorCode.PARAM_EMPTY,
messageSource = messageSource
))
}
// ... 业务逻辑
}
}
```
### 数据源
-禁止直接返回 mock 数据
- ❌ 禁止在 API 调用失败时返回 mock 数据
- ✅ 所有数据必须来自真实 API 或数据库查询
+68 -232
View File
@@ -5,107 +5,41 @@ path: frontend/**
# 前端开发规范
## 代码完成规范
### TODO 处理规则
- **禁止**在代码中添加 TODO 注释
- **必须**根据 TODO 的内容直接完成代码实现
- 如果遇到暂时无法完全实现的功能,应该:
1. 实现一个可用的基础版本
2. 添加清晰的注释说明当前实现的限制和后续改进方向
3. 确保代码可以正常编译和运行
- **禁止**使用 `// TODO: 实现XXX` 这样的注释
- **禁止**使用 `// FIXME:` 或 `// XXX:` 这样的注释
- 如果某个功能需要依赖外部资源(如 API、库等),应该:
1. 先实现一个占位或模拟实现
2. 在注释中说明依赖关系和实现方式
3. 确保代码逻辑完整,不会因为未实现的功能而崩溃
### API 调用实现规则
- **必须**查找相关的 API 文档或接口定义
- **必须**根据 API 文档完成代码实现
- **禁止**在 API 调用处添加 TODO 注释
- 如果 API 文档不完整,应该:
1. 查找项目中已有的类似 API 调用作为参考
2. 查看 API 服务定义(如 `services/api.ts`
3. 查看 API 文档(如 `docs/copy-trading-requirements.md`
4. 实现一个可用的版本,包含错误处理
### 代码完成示例
```typescript
// ❌ 错误:添加 TODO 注释
const fetchAccountBalance = async (accountId: number) => {
// TODO: 调用 API 查询余额
return { balance: '0' }
}
// ✅ 正确:查找 API 定义并完成实现
// 1. 查找 API 服务定义:services/api.ts 中的 accounts.balance
// 2. 查找 API 文档:docs/copy-trading-requirements.md
// 3. 实现完整的 API 调用逻辑
const fetchAccountBalance = async (accountId: number) => {
try {
// 根据 API 服务定义调用接口
const response = await apiService.accounts.balance({ accountId })
// 根据 API 响应格式处理数据
if (response.data.code === 0 && response.data.data) {
return response.data.data
} else {
// API 调用失败时返回默认值
console.warn('查询余额失败,返回默认值:', response.data.msg)
return { balance: '0' }
}
} catch (error) {
console.error('查询余额异常:', error)
// 异常时返回默认值,确保代码可以正常运行
return { balance: '0' }
}
}
```
## 核心原则
- **禁止**在代码中添加 TODO/FIXME/XXX 注释
- **禁止**使用 `any` 类型
- **禁止**硬编码中文或英文文本
- **必须**支持移动端和桌面端
## 技术栈
- **框架**: React + TypeScript
- **UI库**: Ant Design 或 Material-UI(推荐 Ant Design Mobile 用于移动端
- **HTTP客户端**: axios
- **状态管理**: Zustand 或 Redux
- **响应式设计**: 必须支持移动端和桌面端
- **UI 库**: Ant Design(移动端使用 Ant Design Mobile
- **HTTP 客户端**: axios
- **状态管理**: Zustand
- **国际化**: react-i18next
## 移动端适配要求
## 移动端适配
### 响应式设计
- **必须支持移动端和桌面端**
- 使用响应式布局(Responsive Design
- 移动端优先(Mobile First)设计原则
- 支持触摸操作和手势
### 断点设置
### 响应式断点
- **移动端**: < 768px
- **平板**: 768px - 1024px
- **桌面端**: > 1024px
### UI 组件适配
- 使用 Ant Design 的响应式组件
- 移动端使用 Ant Design Mobile(如果使用 Ant Design
- 表格使用虚拟滚动或分页(移动端性能优化)
- 表单使用移动端友好的输入组件
### 布局适配
- 导航栏:移动端使用抽屉菜单,桌面端使用顶部导航
- 列表:移动端使用卡片布局,桌面端使用表格布局
- 按钮:移动端按钮尺寸不小于 44x44px(触摸友好)
- 间距:移动端使用更大的间距,提高可点击区域
### 适配要求
- 使用响应式布局(Mobile First
- 导航栏:移动端抽屉菜单,桌面端顶部导航
- 列表:移动端卡片布局,桌面端表格布局
- 按钮:移动端不小于 44x44px
- 支持触摸操作和手势
### 性能优化
- 图片懒加载
- 代码分割(Code Splitting
- 移动端减少动画效果
- 使用 CSS 媒体查询优化样式
## 代码规范
### 组件规范
### 组件
- 使用函数式组件
- 使用 TypeScript 类型定义
- 组件文件使用 PascalCase 命名
@@ -122,24 +56,18 @@ export const MarketCard: React.FC<MarketProps> = ({ marketId, platform }) => {
};
// ❌ 错误
export const marketCard = (props: any) => { // 禁止使用any
// ...
};
export const marketCard = (props: any) => { }
```
### API调用规范
- 使用 axios 进行 HTTP 请求
### API 调用
- 使用 axios
- 统一错误处理
- 使用 TypeScript 定义响应类型
```typescript
// API服务
import axios from 'axios';
interface Market {
id: string;
marketId: string;
platform: string;
title: string;
}
@@ -147,24 +75,16 @@ export const marketService = {
getMarkets: async (): Promise<Market[]> => {
const response = await axios.get<Market[]>('/api/markets');
return response.data;
},
getMarketById: async (id: string): Promise<Market> => {
const response = await axios.get<Market>(`/api/markets/${id}`);
return response.data;
}
};
```
### 状态管理规范
- 使用 Zustand 或 Redux 管理全局状态
### 状态管理
- 全局状态使用 Zustand
- 本地状态使用 `useState`
- 复杂状态使用 `useReducer`
```typescript
// Zustand Store示例
import { create } from 'zustand';
interface MarketStore {
markets: Market[];
setMarkets: (markets: Market[]) => void;
@@ -176,80 +96,64 @@ export const useMarketStore = create<MarketStore>((set) => ({
}));
```
### USDC 金额格式化规范
- **必须**使用 `formatUSDC` 函数格式化所有 USDC 金额显示
- **禁止**直接使用 `toFixed()` 或 `parseFloat().toFixed()` 格式化 USDC
- **禁止**硬编码小数位数格式化 USDC
- 所有 USDC 金额显示必须统一使用 `formatUSDC` 函数
#### formatUSDC 函数说明
- **位置**: `src/utils/index.ts`
- **功能**: 格式化 USDC 金额,最多显示 4 位小数,自动去除尾随零(截断,不四舍五入)
- **参数**: `value: string | number | undefined | null`
- **返回值**: 格式化后的字符串,如果值为空或无效则返回 `'-'`
#### 使用示例
### USDC 金额格式化
**必须**使用 `formatUSDC` 函数(从 `../utils` 导入)
```typescript
// ✅ 正确:使用 formatUSDC 格式化 USDC 金额
import { formatUSDC } from '../utils'
const BalanceDisplay: React.FC<{ balance: string }> = ({ balance }) => {
return <span>{formatUSDC(balance)} USDC</span>
}
// ✅ 正确:在表格列中使用
const columns = [
{
title: '余额',
dataIndex: 'balance',
render: (balance: string) => `${formatUSDC(balance)} USDC`
}
]
// ✅ 正确:在统计组件中使用
// ✅ 正确
<Statistic
title="总盈亏"
value={formatUSDC(stats?.totalPnl || '0')}
suffix="USDC"
/>
// ❌ 错误:直接使用 toFixed
const balance = parseFloat(value).toFixed(4) // 禁止
// ❌ 错误:硬编码格式化
const balance = `${parseFloat(value).toFixed(2)} USDC` // 禁止
// ❌ 错误:使用自定义格式化函数
const formatBalance = (value: string) => parseFloat(value).toFixed(4) // 禁止
// ❌ 错误
const balance = parseFloat(value).toFixed(4)
```
#### 格式化规则
- **最多显示 4 位小数**:如果金额超过 4 位小数,截断到 4 位(不四舍五入)
- **自动去除尾随零**:去除不必要的尾随零和小数点
- **示例**
- `formatUSDC(1.23)` => `"1.23"`
- `formatUSDC(1.23456)` => `"1.2345"`(截断,不四舍五入)
- `formatUSDC(1.2)` => `"1.2"`
- `formatUSDC(1)` => `"1"`
- `formatUSDC(1.23459)` => `"1.2345"`(截断,不四舍五入)
- `formatUSDC(null)` => `"-"`
- `formatUSDC(undefined)` => `"-"`
**格式化规则**:
- 最多显示 4 位小数(截断,不四舍五入)
- 自动去除尾随零
- 空值返回 `'-'`
#### 工具函数统一导出
- 所有工具函数统一从 `src/utils/index.ts` 导出
- **必须**从 `../utils` 导入工具函数,而不是从具体文件导入
- `ethers.ts` 中的函数也会在 `index.ts` 中统一导出
## 多语言规范
### 使用方式
**必须**使用 `useTranslation` Hook
```typescript
// ✅ 正确:从 utils 统一导入
import { formatUSDC, getAddressFromPrivateKey, isValidWalletAddress } from '../utils'
// ✅ 正确
import { useTranslation } from 'react-i18next'
// ❌ 错误:从具体文件导入
import { formatUSDC } from '../utils/index'
import { getAddressFromPrivateKey } from '../utils/ethers'
const MyComponent: React.FC = () => {
const { t } = useTranslation()
return (
<div>
<h1>{t('page.title')}</h1>
<Button>{t('common.save')}</Button>
</div>
)
}
// ❌ 错误:硬编码文本
<h1>页面标题</h1>
<Button>保存</Button>
```
### 语言文件
- 位置: `src/locales/{locale}/common.json`
- 支持: `zh-CN`、`zh-TW`、`en`
- 键名: 点号分隔的层级结构(如 `notificationSettings.title`
### 添加新翻译
1. 在 `src/locales/zh-CN/common.json` 添加中文
2. 在 `src/locales/zh-TW/common.json` 添加繁体
3. 在 `src/locales/en/common.json` 添加英文
4. 在组件中使用 `t('key')`
## 移动端适配示例
### 响应式布局
@@ -267,29 +171,6 @@ const MyComponent: React.FC = () => {
};
```
### 移动端导航
```typescript
import { Drawer } from 'antd';
const MobileNav: React.FC = () => {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>菜单</Button>
<Drawer
title="导航"
placement="left"
onClose={() => setOpen(false)}
open={open}
>
{/* 导航内容 */}
</Drawer>
</>
);
};
```
### 响应式表格
```typescript
import { Table } from 'antd';
@@ -316,63 +197,18 @@ const ResponsiveTable: React.FC = () => {
### 代码质量
- ❌ 禁止使用 `any` 类型
- ❌ 禁止忽略错误处理
- ❌ 禁止硬编码API地址
- ❌ 禁止硬编码 API 地址
- ❌ 禁止在组件中直接使用 `fetch`
- ❌ 禁止忽略移动端适配
### 类型安全
- ❌ 禁止使用 `any`
- ❌ 禁止忽略 TypeScript 类型检查
- ❌ 禁止使用 `@ts-ignore` 除非有明确原因
### 移动端适配
- ❌ 禁止固定宽度布局
- ❌ 禁止使用过小的触摸目标(< 44x44px
- ❌ 禁止忽略移动端性能优化
- ❌ 禁止使用桌面端专用的交互方式(如 hover)
- ❌ 禁止使用桌面端专用交互(如 hover
### 多语言使用规范
- **必须**使用多语言(i18n)进行所有文本显示
- **禁止**在组件中硬编码中文或英文文本
- **必须**使用 `useTranslation` Hook 获取翻译函数
- **必须**在语言文件中定义所有显示的文本
- **必须**支持至少中文(简体/繁体)和英文
#### 多语言使用示例
```typescript
// ✅ 正确:使用多语言
import { useTranslation } from 'react-i18next'
const MyComponent: React.FC = () => {
const { t } = useTranslation()
return (
<div>
<h1>{t('page.title')}</h1>
<Button>{t('common.save')}</Button>
</div>
)
}
// ❌ 错误:硬编码文本
const MyComponent: React.FC = () => {
return (
<div>
<h1>页面标题</h1> {/* 禁止硬编码 */}
<Button>保存</Button> {/* 禁止硬编码 */}
</div>
)
}
```
#### 语言文件结构
- 语言文件位置:`src/locales/{locale}/common.json`
- 支持的语言:`zh-CN`(简体中文)、`zh-TW`(繁体中文)、`en`(英文)
- 键名使用点号分隔的层级结构,如:`notificationSettings.title`
#### 添加新翻译的步骤
1. 在 `src/locales/zh-CN/common.json` 中添加中文翻译
2. 在 `src/locales/zh-TW/common.json` 中添加繁体中文翻译
3. 在 `src/locales/en/common.json` 中添加英文翻译
4. 在组件中使用 `t('key')` 获取翻译
### 多语言
- ❌ 禁止硬编码中文或英文文本
- ❌ 禁止绕过 `useTranslation` 直接显示文本
+137
View File
@@ -0,0 +1,137 @@
# 🎉 PolyHermes v1.1.7 发布公告
**发布日期:2026年1月7日**
---
## ✨ 新功能
### 💰 支持 Maker Rebates Program 费率
我们新增了对 Polymarket Maker Rebates Program 的支持!系统现在会自动获取并应用最新的费率,帮助您享受更优惠的交易成本。
**这意味着什么?**
- 系统会自动查询并应用最新的费率
- 所有订单(买入、卖出)都会使用正确的费率
- 无需手动配置,系统会自动处理
### 🔧 Docker 部署更灵活
现在您可以通过环境变量轻松配置日志级别,无需修改配置文件!
**新增配置项:**
- `LOG_LEVEL_ROOT` - 系统日志级别(默认:INFO
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
只需在 `.env` 文件中添加这些配置,重启服务即可生效。
---
## 🐛 问题修复
### 修复市场价格查询问题
修复了某些市场无法正确查询价格的问题。现在系统会:
- 优先从链上查询市场价格
- 如果链上查询失败,自动降级到其他数据源
- 提高系统的稳定性和容错性
### 修复自动卖出误判问题
修复了在某些情况下系统会误判市场已卖出,导致创建错误记录的问题。现在系统会:
- 更准确地判断市场状态
- 避免误判导致的错误记录
- 提高仓位管理的准确性
---
## 📝 文档更新
### 更新联系方式
- **Telegram 群组**https://t.me/polyhermes
- 欢迎加入我们的 Telegram 群组,获取最新资讯和技术支持!
### 版本信息更清晰
- README 中新增了 Docker 版本徽章
- 可以一目了然地看到最新的 Docker 镜像版本
---
## 📊 本次更新统计
- **5 个提交**
- **16 个文件变更**
- **主要改进**:费率支持、错误修复、部署优化
---
## 🚀 如何升级
### Docker 部署用户(推荐)
```bash
# 1. 停止当前服务
docker-compose -f docker-compose.prod.yml down
# 2. 拉取最新镜像
docker pull wrbug/polyhermes:latest
# 3. 重新启动服务
docker-compose -f docker-compose.prod.yml up -d
# 4. 查看日志确认升级成功
docker-compose -f docker-compose.prod.yml logs -f
```
### 本地构建用户
```bash
# 1. 拉取最新代码
git pull origin dev
# 2. 切换到 v1.1.7 标签
git checkout v1.1.7
# 3. 重新构建并启动
./deploy.sh
```
---
## ⚠️ 重要提示
### 数据库迁移
**本次更新无需数据库迁移**,可以直接升级,不会影响现有数据。
### 配置变更
- 新增的日志级别配置为**可选配置**
- 如果不配置,系统会使用默认值(INFO/DEBUG)
- 不影响现有功能
---
## 🙏 感谢
感谢所有用户的支持和反馈!如果您在使用过程中遇到任何问题,欢迎:
- 📧 提交 Issuehttps://github.com/WrBug/PolyHermes/issues
- 💬 加入 Telegram 群组:https://t.me/polyhermes
- 🐦 关注 Twitter@polyhermes
---
## 📚 相关链接
- **GitHub 仓库**https://github.com/WrBug/PolyHermes
- **完整更新日志**https://github.com/WrBug/PolyHermes/releases/tag/v1.1.7
- **Docker Hub**https://hub.docker.com/r/wrbug/polyhermes
---
**祝您交易顺利!** 🚀
+128
View File
@@ -1,3 +1,131 @@
# v1.1.8
## 🚀 主要功能
### ⚡ Polymarket Activity WebSocket 双重监听方案
- **新增 PolymarketActivityWsService**:通过 Activity WebSocket 实时监听 Leader 交易
- 延迟 < 100ms,适合快速跟单场景
- 订阅全局 activity 交易流,客户端过滤 Leader 地址
- 支持动态添加/移除 Leader 监听
- 地址筛选忽略大小写,提高匹配准确性
- **双重监听机制**
- Activity WebSocket(低延迟,< 100ms):作为主要监听方式
- On-Chain WebSocket(高可靠性,~2-3s):作为兜底监听方式
- 自动去重,避免重复处理同一笔交易
- **协议支持**
- 支持 `connection_id` 字段
- 优先使用消息中的 `outcomeIndex` 字段,fallback 到从 `outcome` 解析
- **参考文档**
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
### 🔧 支持同一用户创建多个相同 Leader 的跟单配置
- **移除唯一约束**:允许同一用户创建多个跟单同一个 Leader 的配置
- **应用场景**:支持不同参数(比例、过滤条件等)的多配置跟单
- **数据库迁移**`V23__remove_unique_constraint_from_copy_trading.sql`
### 🎯 市场截止时间筛选功能
- **新增市场截止时间过滤**:支持设置跟单配置的最大市场截止时间
- **自动过滤过期市场**:系统会自动跳过超过设定截止时间的市场
- **数据库字段**`max_market_end_date`(时间戳,单位:秒)
- **数据库迁移**`V22__add_max_market_end_date_to_copy_trading.sql`
### 🔍 关键字过滤功能
- **新增关键字过滤**:支持在黑名单或白名单模式下过滤市场标题关键字
- **过滤模式**
- `DISABLED`:禁用关键字过滤
- `BLACKLIST`:黑名单模式(包含关键字的市场会被过滤)
- `WHITELIST`:白名单模式(只允许包含关键字的市场)
- **多关键字支持**:支持多个关键字,以 JSON 数组格式存储
- **数据库字段**`keyword_filter_mode``keywords`JSON 数组)
- **数据库迁移**`V20__add_keyword_filter.sql`
### 📊 订单列表功能重构
- **按市场分组显示**:订单列表按市场分组,便于查看和管理
- **市场信息展示**
- 显示市场标题和 slug
- 支持跳转到 Polymarket 市场页面
- 显示市场基本信息(标题、slug、截止时间等)
- **订单 ID 复制功能**:一键复制订单 ID,方便查询和调试
- **UI/UX 优化**
- 优化订单列表展示样式
- 改进移动端适配
### 📈 市场信息管理和缓存优化
- **新增 MarketService**:统一管理市场信息查询和缓存
- 使用 LRU 缓存提高查询性能
- 支持从 Gamma API 和 CLOB API 查询市场信息
- 自动缓存市场信息,减少 API 调用
- **新增 Market 实体和表**:持久化存储市场信息
- 存储市场标题、slug、eventSlug、endDate 等基本信息
- 支持通过 marketId 快速查询市场信息
- **数据库迁移**
- `V19__create_markets_table.sql`:创建市场信息表
- `V21__add_event_slug_to_markets.sql`:添加 eventSlug 字段
- **市场信息轮询服务**:定期更新市场信息,保持数据新鲜度
## 🐛 Bug 修复
### 前端 TypeScript 类型错误修复
- **修复 InputNumber parser 类型错误**`parser` 函数应返回 `number` 而不是 `string`
- **修复文件**
- `src/pages/CopyTradingOrders/AddModal.tsx`
- `src/pages/CopyTradingOrders/EditModal.tsx`
## 📝 文档更新
- **新增双重监听方案文档**:详细说明 Activity WebSocket 和 On-Chain WebSocket 的双重监听机制
- **新增 Activity WebSocket API 文档**:详细的 API 格式和消息结构说明
- **更新开发规范**JSON 解析规范和 Data Class 规范
## 📊 统计信息
- **61 个文件被修改**
- **+5125 行新增代码**
- **-1942 行删除代码**
- **净增加 3183 行代码**
## 🔄 主要提交
```
d376a82 feat: 添加市场信息管理和订单ID复制功能
2af2c0e feat: 订单列表按市场分组并支持跳转到Polymarket
9ed5190 feat: 添加关键字过滤功能并优化市场 slug 处理
0327eaf feat: 添加市场截止时间筛选功能
a16b6fc feat: 支持同一用户创建多个相同 leader 的跟单配置
19508dc feat: 实现 Polymarket Activity WebSocket 双重监听方案
```
## 🎯 升级建议
1. **数据库迁移**:确保执行所有数据库迁移脚本(V19-V23)
2. **配置更新**:新版本的 `application.properties` 中添加了 Activity WebSocket URL 配置
3. **环境变量**:如果使用 Docker 部署,建议更新 `docker-compose.yml` 中的配置
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.8`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.8)
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
---
# v1.1.7
## 🚀 主要功能
+3
View File
@@ -68,6 +68,9 @@ dependencies {
// Logging
implementation("org.slf4j:slf4j-api")
// Caffeine Cache (LRU)
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
@@ -28,6 +28,23 @@ interface PolymarketGammaApi {
): Response<List<MarketResponse>>
}
/**
* 事件响应(从 MarketResponse.events 解析)
*/
data class EventResponse(
val id: String? = null,
val ticker: String? = null,
val slug: String,
val title: String,
val category: String? = null,
val active: Boolean? = null,
val closed: Boolean? = null,
val archived: Boolean? = null,
val startDate: String? = null,
val endDate: String? = null,
val createdAt: String? = null
)
/**
* 市场响应(根据 Gamma API 文档)
*/
@@ -54,6 +71,7 @@ data class MarketResponse(
val lastTradePrice: Double? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null,
val events: List<EventResponse>? = null, // 事件列表(从 events[0] 获取 slug
// 以下字段可能存在于响应中,但不在标准文档中
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本)
@@ -200,5 +200,51 @@ class CopyOrderTrackingController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询按市场分组的订单列表(买入/卖出)
* POST /api/copy-trading/orders/grouped-by-market
*/
@PostMapping("/grouped-by-market")
fun getOrderListGroupedByMarket(@RequestBody request: MarketGroupedOrdersRequest): ResponseEntity<ApiResponse<MarketGroupedOrdersResponse>> {
return try {
if (request.copyTradingId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_COPY_TRADING_ID_INVALID, messageSource = messageSource))
}
if (request.type.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "订单类型不能为空", messageSource))
}
val validTypes = listOf("buy", "sell")
if (!validTypes.contains(request.type.lowercase())) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "订单类型必须是 buy 或 sell", messageSource))
}
val result: Result<MarketGroupedOrdersResponse> = runBlocking {
when (request.type.lowercase()) {
"buy" -> statisticsService.getBuyOrderListGroupedByMarket(request)
"sell" -> statisticsService.getSellOrderListGroupedByMarket(request)
else -> Result.failure(IllegalArgumentException("不支持的订单类型: ${request.type}"))
}
}
result.fold(
onSuccess = { response: MarketGroupedOrdersResponse ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e: Throwable ->
logger.error("查询按市场分组的订单列表失败: copyTradingId=${request.copyTradingId}, type=${request.type}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
}
}
)
} catch (e: Exception) {
logger.error("查询按市场分组的订单列表异常: copyTradingId=${request.copyTradingId}, type=${request.type}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
}
}
}
@@ -125,7 +125,8 @@ data class AccountPositionDto(
val proxyAddress: String,
val marketId: String,
val marketTitle: String?,
val marketSlug: String?,
val marketSlug: String?, // 显示用的 slug
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketIcon: String?, // 市场图标 URL
val side: String, // 结果名称(如 "YES", "NO", "Pakistan" 等)
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2...),用于计算 tokenId
@@ -0,0 +1,64 @@
package com.wrbug.polymarketbot.dto
import com.google.gson.annotations.SerializedName
/**
* Activity WebSocket 交易消息 DTO
* 根据 Polymarket RTDS Activity WebSocket API 格式定义
*/
data class ActivityTradeMessage(
val topic: String = "", // "activity"
val type: String = "", // "trades"
val timestamp: Long? = null, // 消息时间戳(可选)
@SerializedName("connection_id")
val connectionId: String? = null, // 连接 ID(可选,由服务器返回)
val payload: ActivityTradePayload = ActivityTradePayload() // 交易数据
)
/**
* Activity Trade Payload
*/
data class ActivityTradePayload(
val asset: String = "", // Token ID (用于下单)
@SerializedName("conditionId")
val conditionId: String = "", // Market condition ID
@SerializedName("eventSlug")
val eventSlug: String? = null, // 事件 slug
val slug: String? = null, // 市场 slug
val outcome: String? = null, // 结果方向 (Yes/No/Up/Down)
@SerializedName("outcomeIndex")
val outcomeIndex: Int? = null, // 结果索引 (0=Yes/Up, 1=No/Down) - 优先使用此字段
val side: String = "", // 交易方向 (BUY/SELL)
// price 和 size 可能是数字或字符串,使用 Any 类型,后续转换为 String
val price: Any? = null, // 交易价格
val size: Any? = null, // 交易数量 (shares)
val timestamp: Any? = null, // Unix 时间戳(可能是秒或毫秒,可能是数字或字符串)
@SerializedName("transactionHash")
val transactionHash: String? = null, // 交易哈希
val trader: ActivityTrader? = null, // 交易者信息对象(优先)
@SerializedName("proxyWallet")
val proxyWallet: String? = null, // 交易者地址(fallback,如果 trader 不存在)
val name: String? = null // 交易者名称(fallback,如果 trader 不存在)
)
/**
* Activity Trader 信息
*/
data class ActivityTrader(
val name: String? = null, // 交易者用户名(可选)
val address: String? = null // 交易者钱包地址 ⭐ 关键字段
)
@@ -37,9 +37,13 @@ data class CopyTradingCreateRequest(
// 最大仓位配置
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
// 新增配置字段
val configName: String? = null, // 配置名(可选)
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
)
/**
@@ -71,9 +75,13 @@ data class CopyTradingUpdateRequest(
// 最大仓位配置
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
// 新增配置字段
val configName: String? = null, // 配置名(可选,但提供时必须非空)
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
)
/**
@@ -142,9 +150,13 @@ data class CopyTradingDto(
// 最大仓位配置
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
// 新增配置字段
val configName: String? = null, // 配置名(可选)
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
val createdAt: Long,
val updatedAt: Long
)
@@ -40,6 +40,10 @@ data class BuyOrderInfo(
val orderId: String,
val leaderTradeId: String,
val marketId: String,
val marketTitle: String? = null, // 市场名称
val marketSlug: String? = null, // 市场 slug(用于显示)
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
val side: String,
val quantity: String,
val price: String,
@@ -57,6 +61,10 @@ data class SellOrderInfo(
val orderId: String,
val leaderTradeId: String,
val marketId: String,
val marketTitle: String? = null, // 市场名称
val marketSlug: String? = null, // 市场 slug(用于显示)
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
val side: String,
val quantity: String,
val price: String,
@@ -71,6 +79,11 @@ data class SellOrderInfo(
data class MatchedOrderInfo(
val sellOrderId: String,
val buyOrderId: String,
val marketId: String? = null, // 市场ID(从买入订单获取)
val marketTitle: String? = null, // 市场名称
val marketSlug: String? = null, // 市场 slug(用于显示)
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
val matchedQuantity: String,
val buyPrice: String,
val sellPrice: String,
@@ -103,6 +116,52 @@ data class OrderTrackingRequest(
val buyOrderId: String? = null
)
/**
* 按市场分组的订单查询请求
*/
data class MarketGroupedOrdersRequest(
val copyTradingId: Long,
val type: String, // buy, sell, matched
val page: Int? = 1,
val limit: Int? = 20
)
/**
* 单个市场的订单统计信息
*/
data class MarketOrderStats(
val count: Long,
val totalAmount: String, // 总金额
val totalPnl: String?, // 总盈亏(买入订单未实现盈亏,此字段为空)
val fullyMatched: Boolean, // 是否全部成交
val fullyMatchedCount: Long, // 完全成交的订单数
val partiallyMatchedCount: Long, // 部分成交的订单数
val filledCount: Long // 未成交的订单数
)
/**
* 单个市场分组的响应数据
*/
data class MarketOrderGroup(
val marketId: String,
val marketTitle: String?,
val marketSlug: String?, // 显示用的 slug
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketCategory: String?,
val stats: MarketOrderStats,
val orders: List<Any> // BuyOrderInfo, SellOrderInfo 或 MatchedOrderInfo 的列表
)
/**
* 按市场分组的订单列表响应
*/
data class MarketGroupedOrdersResponse(
val list: List<MarketOrderGroup>,
val total: Long, // 市场总数
val page: Int,
val limit: Int
)
/**
* 统计查询请求
*/
@@ -25,7 +25,8 @@ data class MarketDto(
val liquidityNum: Double?,
val bestBid: Double?,
val bestAsk: Double?,
val lastTradePrice: Double?
val lastTradePrice: Double?,
val events: List<MarketDto>? = null // 事件列表(从 events[0] 获取 slug
)
/**
@@ -37,9 +38,9 @@ data class OutcomeDto(
)
/**
* 事件 DTO
* 事件 DTO(用于其他 API 调用)
*/
data class EventDto(
data class EventListDto(
val id: String,
val title: String,
val category: String,
@@ -55,7 +56,7 @@ data class SeriesDto(
val id: String,
val title: String,
val category: String,
val events: List<EventDto>?,
val events: List<EventListDto>?,
val createdAt: Long? // 时间戳(毫秒)
)
@@ -70,4 +71,3 @@ data class CommentDto(
val createdAt: Long, // 时间戳(毫秒)
val user: String?
)
@@ -8,12 +8,7 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
* 跟单配置实体(独立配置,不再绑定模板)
*/
@Entity
@Table(
name = "copy_trading",
uniqueConstraints = [
UniqueConstraint(columnNames = ["account_id", "leader_id"])
]
)
@Table(name = "copy_trading")
data class CopyTrading(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -91,6 +86,13 @@ data class CopyTrading(
@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(黑名单)
@Column(name = "keywords", columnDefinition = "JSON")
val keywords: String? = null, // 关键字列表(JSON数组),例如:["NBA", "足球", "NBA总决赛"],当keywordFilterMode为DISABLED时为NULL
// 新增配置字段
@Column(name = "config_name", length = 255)
val configName: String? = null, // 配置名(可选)
@@ -98,6 +100,9 @@ data class CopyTrading(
@Column(name = "push_failed_orders", nullable = false)
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
@Column(name = "max_market_end_date")
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -0,0 +1,60 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* 市场信息实体
* 用于缓存市场的基本信息(名称、slug等)
*/
@Entity
@Table(name = "markets", indexes = [
Index(name = "idx_market_id", columnList = "market_id", unique = true)
])
data class Market(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "market_id", unique = true, nullable = false, length = 100)
val marketId: String, // 市场IDcondition ID
@Column(name = "title", nullable = false, length = 500)
val title: String, // 市场名称(question
@Column(name = "slug", length = 200)
val slug: String? = null, // 市场slug(用于显示)
@Column(name = "event_slug", length = 200)
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
@Column(name = "category", length = 50)
val category: String? = null, // 市场分类
@Column(name = "icon", length = 500)
val icon: String? = null, // 市场图标URL
@Column(name = "image", length = 500)
val image: String? = null, // 市场图片URL
@Column(name = "description", columnDefinition = "TEXT")
val description: String? = null, // 市场描述
@Column(name = "active", nullable = false)
val active: Boolean = true, // 是否活跃
@Column(name = "closed", nullable = false)
val closed: Boolean = false, // 是否已关闭
@Column(name = "archived", nullable = false)
val archived: Boolean = false, // 是否已归档
@Column(name = "end_date")
val endDate: Long? = null, // 市场截止时间(毫秒时间戳)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -21,12 +21,12 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
fun findByLeaderId(leaderId: Long): List<CopyTrading>
/**
* 根据账户ID和Leader ID查找跟单
* 根据账户ID和Leader ID查找跟单列表
*/
fun findByAccountIdAndLeaderId(
accountId: Long,
leaderId: Long
): CopyTrading?
): List<CopyTrading>
/**
* 查找所有启用的跟单
@@ -0,0 +1,12 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.Market
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface MarketRepository : JpaRepository<Market, Long> {
fun findByMarketId(marketId: String): Market?
fun findByMarketIdIn(marketIds: List<String>): List<Market>
}
@@ -8,8 +8,10 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.getEventSlug
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.common.PolymarketApiKeyService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
@@ -36,6 +38,7 @@ class AccountService(
private val orderPushService: OrderPushService,
private val orderSigningService: OrderSigningService,
private val cryptoUtils: CryptoUtils,
private val marketService: MarketService, // 市场信息服务
private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖
private val relayClientService: RelayClientService,
private val jsonUtils: JsonUtils
@@ -656,7 +659,8 @@ class AccountService(
proxyAddress = account.proxyAddress,
marketId = pos.conditionId ?: "",
marketTitle = pos.title ?: "",
marketSlug = pos.slug ?: "",
marketSlug = pos.slug ?: "", // 显示用的 slug
eventSlug = pos.eventSlug, // 跳转用的 slug(从 events[0].slug 获取)
marketIcon = pos.icon, // 市场图标
side = pos.outcome ?: "",
outcomeIndex = pos.outcomeIndex, // 添加 outcomeIndex
@@ -933,31 +937,17 @@ class AccountService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: request.marketId
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(request.marketId)
val marketTitle = market?.title ?: request.marketId
val marketSlug = market?.eventSlug // 跳转用的 slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
telegramNotificationService?.sendOrderSuccessNotification(
orderId = orderId,
marketTitle = marketTitle,
@@ -1001,31 +991,17 @@ class AccountService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: request.marketId
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(request.marketId)
val marketTitle = market?.title ?: request.marketId
val marketSlug = market?.eventSlug // 跳转用的 slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = request.marketId,
@@ -1059,34 +1035,20 @@ class AccountService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: request.marketId
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(request.marketId)
val marketTitle = market?.title ?: request.marketId
val marketSlug = market?.eventSlug // 跳转用的 slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
// 只传递后端返回的 msg,不传递完整堆栈
val errorMsg = orderResponse.body()?.errorMsg ?: "创建订单失败"
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = request.marketId,
@@ -62,6 +62,19 @@ class PositionCheckService(
// 记录已发送提示的配置(避免重复推送)
private val notifiedConfigs = ConcurrentHashMap<Long, Long>() // accountId/copyTradingId -> lastNotificationTime
// 待检查的仓位记录(延迟检测机制)
// key: "accountId_marketId_outcomeIndex_copyTradingId"
// value: PendingPositionCheck(包含订单列表和首次检测时间)
private data class PendingPositionCheck(
val accountId: Long,
val marketId: String,
val outcomeIndex: Int,
val copyTradingId: Long,
val orders: List<CopyOrderTracking>,
val firstDetectedTime: Long // 首次检测到仓位不存在的时间
)
private val pendingPositionChecks = ConcurrentHashMap<String, PendingPositionCheck>()
// 同步锁,确保订阅任务的启动和停止是线程安全的
private val lock = Any()
@@ -73,6 +86,7 @@ class PositionCheckService(
logger.info("PositionCheckService 初始化,订阅仓位轮训事件")
startSubscription()
startCacheCleanup()
startPendingPositionCheckTask()
}
/**
@@ -132,6 +146,121 @@ class PositionCheckService(
}
}
/**
* 启动待检查仓位的定期检查任务
* 每30秒检查一次,如果超过3分钟且确实不存在,则标记为已卖出
*/
private fun startPendingPositionCheckTask() {
scope.launch(Dispatchers.IO) {
while (isActive) {
try {
delay(30000) // 每30秒检查一次
checkPendingPositions()
} catch (e: Exception) {
logger.error("检查待检查仓位异常: ${e.message}", e)
}
}
}
}
/**
* 检查待检查的仓位
* 如果超过3分钟且确实不存在,则标记为已卖出
* 如果存在,则删除记录
*/
private suspend fun checkPendingPositions() {
if (pendingPositionChecks.isEmpty()) {
return
}
try {
// 获取最新的仓位数据
val result = accountService.getAllPositions()
if (result.isFailure) {
logger.warn("获取仓位数据失败,跳过待检查仓位验证: ${result.exceptionOrNull()?.message}")
return
}
val positionListResponse = result.getOrNull() ?: return
val currentPositions = positionListResponse.currentPositions
// 按账户和市场分组当前仓位
val positionsByAccountAndMarket = currentPositions.groupBy {
"${it.accountId}_${it.marketId}_${it.outcomeIndex ?: 0}"
}
val now = System.currentTimeMillis()
val checkDelay = 180000L // 3分钟 = 180000毫秒
val toRemove = mutableListOf<String>()
val toMarkAsSold = mutableListOf<PendingPositionCheck>()
// 遍历所有待检查的仓位
for ((key, pendingCheck) in pendingPositionChecks) {
// 先过滤出仍然有效的订单(remainingQuantity > 0
val validOrders = pendingCheck.orders.filter { order ->
// 重新从数据库查询订单状态,确保数据是最新的
val currentOrder = copyOrderTrackingRepository.findById(order.id!!).orElse(null)
currentOrder != null && currentOrder.remainingQuantity > BigDecimal.ZERO
}
// 如果没有有效订单了,删除记录
if (validOrders.isEmpty()) {
toRemove.add(key)
logger.info("待检查仓位的订单已全部处理,删除记录: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}")
continue
}
val positionKey = "${pendingCheck.accountId}_${pendingCheck.marketId}_${pendingCheck.outcomeIndex}"
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
if (position != null) {
// 仓位存在,删除记录
toRemove.add(key)
logger.info("待检查仓位已恢复,删除记录: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}, elapsedTime=${now - pendingCheck.firstDetectedTime}ms")
} else {
// 仓位不存在,检查是否超过3分钟
val elapsedTime = now - pendingCheck.firstDetectedTime
if (elapsedTime >= checkDelay) {
// 超过3分钟且确实不存在,标记为已卖出(使用有效订单)
toMarkAsSold.add(pendingCheck.copy(orders = validOrders))
toRemove.add(key)
logger.info("待检查仓位超过3分钟仍不存在,标记为已卖出: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}, elapsedTime=${elapsedTime}ms, validOrderCount=${validOrders.size}, originalOrderCount=${pendingCheck.orders.size}")
} else {
// 未超过3分钟,更新订单列表(移除已处理的订单)
if (validOrders.size < pendingCheck.orders.size) {
pendingPositionChecks[key] = pendingCheck.copy(orders = validOrders)
logger.debug("更新待检查仓位记录,移除已处理的订单: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, validOrderCount=${validOrders.size}, originalOrderCount=${pendingCheck.orders.size}")
}
logger.debug("待检查仓位仍不存在,继续等待: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}, elapsedTime=${elapsedTime}ms, remainingTime=${checkDelay - elapsedTime}ms")
}
}
}
// 删除已恢复或已处理的记录
toRemove.forEach { key ->
pendingPositionChecks.remove(key)
}
// 标记为已卖出
for (pendingCheck in toMarkAsSold) {
try {
val currentPrice = getCurrentMarketPrice(pendingCheck.marketId, pendingCheck.outcomeIndex)
updateOrdersAsSold(
pendingCheck.orders,
currentPrice,
pendingCheck.copyTradingId,
pendingCheck.marketId,
pendingCheck.outcomeIndex
)
} catch (e: Exception) {
logger.error("标记待检查仓位为已卖出失败: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, error=${e.message}", e)
}
}
} catch (e: Exception) {
logger.error("检查待检查仓位异常: ${e.message}", e)
}
}
/**
* 清理过期的缓存条目(超过2小时的记录)
*/
@@ -163,8 +292,16 @@ class PositionCheckService(
notifiedConfigs.remove(key)
}
if (expiredPositions.isNotEmpty() || expiredProcessed.isNotEmpty() || expiredConfigs.isNotEmpty()) {
logger.debug("清理过期缓存: positions=${expiredPositions.size}, processed=${expiredProcessed.size}, configs=${expiredConfigs.size}")
// 清理过期的待检查仓位记录(超过1小时的记录,正常情况下应该在3分钟内处理完)
val expiredPendingChecks = pendingPositionChecks.entries.filter { (_, check) ->
(now - check.firstDetectedTime) > 3600000 // 1小时
}
expiredPendingChecks.forEach { (key, _) ->
pendingPositionChecks.remove(key)
}
if (expiredPositions.isNotEmpty() || expiredProcessed.isNotEmpty() || expiredConfigs.isNotEmpty() || expiredPendingChecks.isNotEmpty()) {
logger.debug("清理过期缓存: positions=${expiredPositions.size}, processed=${expiredProcessed.size}, configs=${expiredConfigs.size}, pendingChecks=${expiredPendingChecks.size}")
}
}
@@ -371,31 +508,48 @@ class PositionCheckService(
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
if (position == null) {
// 仓位不存在,检查订单创建时间
// 只有当订单创建时间超过2分钟时,才认为仓位被出售了
// 这样可以避免刚创建的订单因为API延迟而被误判为已卖出
// 仓位不存在,使用延迟检测机制
// 先检查订单创建时间,只有超过2分钟的订单才进入延迟检测
val now = System.currentTimeMillis()
val ordersToMarkAsSold = orders.filter { order ->
val ordersToCheck = orders.filter { order ->
val orderAge = now - order.createdAt
orderAge > 120000 // 2分钟 = 120000毫秒
}
if (ordersToMarkAsSold.isNotEmpty()) {
// 有订单创建时间超过2分钟,认为仓位已被出售
try {
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSold(ordersToMarkAsSold, currentPrice, copyTrading.id, marketId, outcomeIndex)
logger.debug("仓位不存在且订单创建时间超过2分钟,标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${ordersToMarkAsSold.size}")
} catch (e: Exception) {
logger.warn("无法获取市场价格,跳过标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
// 无法获取价格时,跳过该市场的处理,等待下次检查时再试
continue
if (ordersToCheck.isNotEmpty()) {
// 有订单创建时间超过2分钟,记录到待检查列表
val checkKey = "${copyTrading.accountId}_${marketId}_${outcomeIndex}_${copyTrading.id}"
// 如果已经存在记录,更新订单列表(可能订单状态有变化)
val existingCheck = pendingPositionChecks[checkKey]
if (existingCheck == null) {
// 首次检测到,记录
pendingPositionChecks[checkKey] = PendingPositionCheck(
accountId = copyTrading.accountId,
marketId = marketId,
outcomeIndex = outcomeIndex,
copyTradingId = copyTrading.id!!,
orders = ordersToCheck,
firstDetectedTime = now
)
logger.info("首次检测到仓位不存在,记录待检查: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, orderCount=${ordersToCheck.size}, positionKey=$positionKey")
} else {
// 已存在记录,更新订单列表(可能订单状态有变化)
pendingPositionChecks[checkKey] = existingCheck.copy(orders = ordersToCheck)
logger.debug("更新待检查仓位记录: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, orderCount=${ordersToCheck.size}, elapsedTime=${now - existingCheck.firstDetectedTime}ms")
}
} else {
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}")
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}, positionKey=$positionKey")
}
} else {
// 有仓位,先检查是否有对应的待检查记录,如果有则删除(仓位已恢复)
val checkKey = "${copyTrading.accountId}_${marketId}_${outcomeIndex}_${copyTrading.id}"
val pendingCheck = pendingPositionChecks.remove(checkKey)
if (pendingCheck != null) {
logger.info("仓位已恢复,删除待检查记录: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, elapsedTime=${System.currentTimeMillis() - pendingCheck.firstDetectedTime}ms")
}
// 有仓位,按订单下单顺序(FIFO)更新状态
// 计算逻辑:
// 1. 总订单数量 = 所有未卖出订单的剩余数量总和
@@ -0,0 +1,173 @@
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
/**
* 市场信息轮询服务
* 定期检查买入订单的市场信息是否缺失,如果缺失则从API获取并更新
*/
@Service
class MarketPollingService(
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val marketService: MarketService
) {
private val logger = LoggerFactory.getLogger(MarketPollingService::class.java)
@Value("\${market.polling.interval:30000}")
private var pollingInterval: Long = 30000 // 轮询间隔(毫秒),默认30秒
@Value("\${market.polling.batch.size:50}")
private var batchSize: Int = 50 // 批量处理大小,每次最多处理50个市场
// 协程作用域和任务
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var pollingJob: Job? = null
// 同步锁,确保轮询任务的启动和停止是线程安全的
private val lock = Any()
/**
* 初始化服务(后端启动时直接启动轮询)
*/
@PostConstruct
fun init() {
logger.info("MarketPollingService 初始化,启动市场信息轮询任务,轮询间隔: ${pollingInterval}ms (${pollingInterval / 1000 / 60}分钟)")
startPolling()
}
/**
* 清理资源
*/
@PreDestroy
fun destroy() {
synchronized(lock) {
pollingJob?.cancel()
pollingJob = null
}
scope.cancel()
}
/**
* 启动轮询任务
*/
private fun startPolling() {
synchronized(lock) {
// 如果已经有轮询任务在运行,先取消
pollingJob?.cancel()
// 启动新的轮询任务
pollingJob = scope.launch(Dispatchers.IO) {
// 启动时立即执行一次
try {
checkAndUpdateMissingMarkets()
} catch (e: Exception) {
logger.error("初始检查市场信息失败: ${e.message}", e)
}
// 然后按间隔定期执行
while (isActive) {
try {
delay(pollingInterval)
checkAndUpdateMissingMarkets()
} catch (e: Exception) {
logger.error("轮询市场信息失败: ${e.message}", e)
}
}
}
}
}
/**
* 检查并更新缺失的市场信息
*/
private suspend fun checkAndUpdateMissingMarkets() {
try {
logger.debug("开始检查缺失的市场信息...")
// 1. 获取所有买入订单的市场ID(去重)
val allOrders = copyOrderTrackingRepository.findAll()
val marketIds = allOrders.map { it.marketId }.distinct()
if (marketIds.isEmpty()) {
logger.debug("没有找到任何订单,跳过市场信息检查")
return
}
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
// 2. 检查哪些市场信息在数据库中缺失
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
val missingMarketIds = marketIds.filter { it !in existingMarketIds }
// 过滤掉空字符串和无效的市场ID
val validMissingMarketIds = missingMarketIds.filter {
it.isNotBlank() && it.startsWith("0x")
}
if (validMissingMarketIds.isEmpty()) {
logger.debug("所有市场信息都已存在,无需更新")
return
}
logger.info("发现 ${validMissingMarketIds.size} 个缺失的市场信息,开始批量更新...")
// 3. 批量从API获取缺失的市场信息(分批处理,避免一次性请求过多)
val batches = validMissingMarketIds.chunked(batchSize)
var successCount = 0
var failCount = 0
for ((index, batch) in batches.withIndex()) {
try {
logger.debug("处理第 ${index + 1}/${batches.size} 批,包含 ${batch.size} 个市场: ${batch.take(3).joinToString(", ")}${if (batch.size > 3) "..." else ""}")
// 使用 MarketService 的批量获取方法
// 这个方法会尝试从API获取并保存到数据库
val markets = marketService.getMarkets(batch)
val batchSuccessCount = markets.size
val batchFailCount = batch.size - batchSuccessCount
successCount += batchSuccessCount
failCount += batchFailCount
if (batchFailCount > 0) {
logger.warn("${index + 1} 批中有 ${batchFailCount} 个市场信息获取失败")
}
// 避免请求过于频繁,每批之间稍作延迟
if (index < batches.size - 1) {
delay(1000) // 延迟1秒,避免API限流
}
} catch (e: Exception) {
logger.error("批量获取市场信息失败: batch=${batch.take(5).joinToString(", ")}..., error=${e.message}", e)
failCount += batch.size
}
}
logger.info("市场信息更新完成: 成功=${successCount}, 失败=${failCount}, 总计=${validMissingMarketIds.size}")
} catch (e: Exception) {
logger.error("检查并更新市场信息异常: ${e.message}", e)
}
}
/**
* 手动触发检查(用于测试或手动刷新)
*/
fun triggerCheck() {
scope.launch(Dispatchers.IO) {
try {
checkAndUpdateMissingMarkets()
} catch (e: Exception) {
logger.error("手动触发检查市场信息失败: ${e.message}", e)
}
}
}
}
@@ -0,0 +1,234 @@
package com.wrbug.polymarketbot.service.common
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.wrbug.polymarketbot.api.MarketResponse
import com.wrbug.polymarketbot.api.PolymarketGammaApi
import com.wrbug.polymarketbot.entity.Market
import com.wrbug.polymarketbot.repository.MarketRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.getEventSlug
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.Instant
import java.time.format.DateTimeFormatter
/**
* 市场信息服务
* 负责缓存和管理市场信息
*/
@Service
class MarketService(
val marketRepository: MarketRepository, // 改为 public,供 MarketPollingService 使用
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(MarketService::class.java)
// LRU 缓存(避免频繁查询数据库),最多缓存 200 条记录
private val marketCache: Cache<String, Market> = Caffeine.newBuilder()
.maximumSize(200) // 最多缓存 200 条记录
.build()
/**
* 根据市场ID获取市场信息
* 优先从缓存获取,如果不存在则从数据库查询,如果数据库也没有则从API获取并保存
*/
fun getMarket(marketId: String): Market? {
// 1. 从缓存获取
marketCache.getIfPresent(marketId)?.let { return it }
// 2. 从数据库查询
val market = marketRepository.findByMarketId(marketId)
if (market != null) {
marketCache.put(marketId, market)
return market
}
// 3. 从API获取(异步,不阻塞)
runBlocking {
try {
fetchAndSaveMarket(marketId)
} catch (e: Exception) {
logger.warn("获取市场信息失败: marketId=$marketId, error=${e.message}")
}
}
// 再次从数据库查询(API可能已经保存)
return marketRepository.findByMarketId(marketId)?.also {
marketCache.put(marketId, it)
}
}
/**
* 批量获取市场信息
*/
fun getMarkets(marketIds: List<String>): Map<String, Market> {
val result = mutableMapOf<String, Market>()
val missingIds = mutableListOf<String>()
// 1. 从缓存和数据库获取
for (marketId in marketIds) {
val market = getMarket(marketId)
if (market != null) {
result[marketId] = market
} else {
missingIds.add(marketId)
}
}
// 2. 批量从API获取缺失的市场信息
if (missingIds.isNotEmpty()) {
runBlocking {
try {
fetchAndSaveMarkets(missingIds)
} catch (e: Exception) {
logger.warn("批量获取市场信息失败: marketIds=$missingIds, error=${e.message}")
}
}
// 再次从数据库查询
val savedMarkets = marketRepository.findByMarketIdIn(missingIds)
for (market in savedMarkets) {
result[market.marketId] = market
marketCache.put(market.marketId, market)
}
}
return result
}
/**
* 从API获取市场信息并保存到数据库
*/
private suspend fun fetchAndSaveMarket(marketId: String): Market? {
return try {
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.listMarkets(conditionIds = listOf(marketId))
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
if (markets.isNotEmpty()) {
val marketResponse = markets.first()
saveMarketFromResponse(marketId, marketResponse)
} else {
null
}
} else {
null
}
} catch (e: Exception) {
logger.error("从API获取市场信息失败: marketId=$marketId, error=${e.message}", e)
null
}
}
/**
* 批量从API获取市场信息并保存到数据库
*/
private suspend fun fetchAndSaveMarkets(marketIds: List<String>) {
if (marketIds.isEmpty()) return
try {
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.listMarkets(conditionIds = marketIds)
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
val marketMap = markets.associateBy { it.conditionId ?: "" }
for (marketId in marketIds) {
val marketResponse = marketMap[marketId]
if (marketResponse != null) {
saveMarketFromResponse(marketId, marketResponse)
}
}
}
} catch (e: Exception) {
logger.error("批量从API获取市场信息失败: marketIds=$marketIds, error=${e.message}", e)
}
}
/**
* 从API响应保存市场信息到数据库
*/
private fun saveMarketFromResponse(marketId: String, marketResponse: MarketResponse): Market? {
return try {
val existingMarket = marketRepository.findByMarketId(marketId)
// 保存原来的 slug(用于显示)
val slug = marketResponse.slug
// 保存跳转用的 slug(从 events[0].slug 获取)
val eventSlug = marketResponse.getEventSlug()
val market = if (existingMarket != null) {
// 更新现有市场信息
existingMarket.copy(
title = marketResponse.question ?: existingMarket.title,
slug = slug ?: existingMarket.slug,
eventSlug = eventSlug ?: existingMarket.eventSlug,
category = marketResponse.category ?: existingMarket.category,
icon = marketResponse.icon ?: existingMarket.icon,
image = marketResponse.image ?: existingMarket.image,
description = marketResponse.description ?: existingMarket.description,
active = marketResponse.active ?: existingMarket.active,
closed = marketResponse.closed ?: existingMarket.closed,
archived = marketResponse.archived ?: existingMarket.archived,
endDate = parseEndDate(marketResponse.endDate),
updatedAt = System.currentTimeMillis()
)
} else {
// 创建新市场信息
Market(
marketId = marketId,
title = marketResponse.question ?: marketId,
slug = slug,
eventSlug = eventSlug,
category = marketResponse.category,
icon = marketResponse.icon,
image = marketResponse.image,
description = marketResponse.description,
active = marketResponse.active ?: true,
closed = marketResponse.closed ?: false,
archived = marketResponse.archived ?: false,
endDate = parseEndDate(marketResponse.endDate),
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
}
val savedMarket = marketRepository.save(market)
marketCache.put(marketId, savedMarket)
savedMarket
} catch (e: Exception) {
logger.error("保存市场信息失败: marketId=$marketId, error=${e.message}", e)
null
}
}
/**
* 清除缓存(用于测试或手动刷新)
*/
fun clearCache() {
marketCache.invalidateAll()
}
/**
* 解析市场截止时间(ISO 8601 格式)
*/
private fun parseEndDate(endDate: String?): Long? {
if (endDate.isNullOrBlank()) {
return null
}
return try {
// ISO 8601 格式,例如:2025-03-15T12:00:00Z
Instant.parse(endDate).toEpochMilli()
} catch (e: Exception) {
logger.warn("解析市场截止时间失败: endDate=$endDate, error=${e.message}")
null
}
}
}
@@ -6,6 +6,7 @@ import com.wrbug.polymarketbot.util.gt
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 org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.accounts.AccountService
@@ -20,7 +21,8 @@ import java.math.BigDecimal
class CopyTradingFilterService(
private val clobService: PolymarketClobService,
private val accountService: AccountService,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val jsonUtils: JsonUtils
) {
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
@@ -32,6 +34,8 @@ class CopyTradingFilterService(
* @param tradePrice Leader 交易价格,用于价格区间检查
* @param copyOrderAmount 跟单金额(USDC),用于仓位检查,如果为null则不进行仓位检查
* @param marketId 市场ID,用于仓位检查(按市场过滤仓位)
* @param marketTitle 市场标题,用于关键字过滤
* @param marketEndDate 市场截止时间,用于市场截止时间检查
* @return 过滤结果
*/
suspend fun checkFilters(
@@ -39,9 +43,27 @@ class CopyTradingFilterService(
tokenId: String,
tradePrice: BigDecimal? = null, // Leader 交易价格,用于价格区间检查
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
marketId: String? = null // 市场ID,用于仓位检查(按市场过滤仓位)
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
marketTitle: String? = null, // 市场标题,用于关键字过滤
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
): FilterResult {
// 1. 价格区间检查(如果配置了价格区间
// 1. 关键字过滤检查(如果配置了关键字过滤
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
val keywordCheck = checkKeywordFilter(copyTrading, marketTitle)
if (!keywordCheck.isPassed) {
return keywordCheck
}
}
// 1.5. 市场截止时间检查(如果配置了市场截止时间限制)
if (copyTrading.maxMarketEndDate != null) {
val marketEndDateCheck = checkMarketEndDate(copyTrading, marketEndDate)
if (!marketEndDateCheck.isPassed) {
return marketEndDateCheck
}
}
// 2. 价格区间检查(如果配置了价格区间)
if (tradePrice != null) {
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
if (!priceRangeCheck.isPassed) {
@@ -49,7 +71,7 @@ class CopyTradingFilterService(
}
}
// 2. 检查是否需要获取订单簿
// 3. 检查是否需要获取订单簿
// 只有在配置了需要订单簿的过滤条件时才获取
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
@@ -58,7 +80,7 @@ class CopyTradingFilterService(
return FilterResult.passed()
}
// 3. 获取订单簿(仅在需要时,只请求一次)
// 4. 获取订单簿(仅在需要时,只请求一次)
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (!orderbookResult.isSuccess) {
val error = orderbookResult.exceptionOrNull()
@@ -68,7 +90,7 @@ class CopyTradingFilterService(
val orderbook = orderbookResult.getOrNull()
?: return FilterResult.orderbookEmpty()
// 4. 买一卖一价差过滤(如果配置了)
// 5. 买一卖一价差过滤(如果配置了)
if (copyTrading.maxSpread != null) {
val spreadCheck = checkSpread(copyTrading, orderbook)
if (!spreadCheck.isPassed) {
@@ -76,7 +98,7 @@ class CopyTradingFilterService(
}
}
// 5. 订单深度过滤(如果配置了,检查所有方向)
// 6. 订单深度过滤(如果配置了,检查所有方向)
if (copyTrading.minOrderDepth != null) {
val depthCheck = checkOrderDepth(copyTrading, orderbook)
if (!depthCheck.isPassed) {
@@ -84,7 +106,7 @@ class CopyTradingFilterService(
}
}
// 6. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
if (copyOrderAmount != null && marketId != null) {
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
if (!positionCheck.isPassed) {
@@ -95,6 +117,65 @@ class CopyTradingFilterService(
return FilterResult.passed(orderbook)
}
/**
* 检查关键字过滤
* @param copyTrading 跟单配置
* @param marketTitle 市场标题
* @return 过滤结果
*/
private fun checkKeywordFilter(
copyTrading: CopyTrading,
marketTitle: String?
): FilterResult {
// 如果未启用关键字过滤,直接通过
if (copyTrading.keywordFilterMode == null || copyTrading.keywordFilterMode == "DISABLED") {
return FilterResult.passed()
}
// 如果没有市场标题,无法进行关键字过滤,为了安全起见,不通过
if (marketTitle.isNullOrBlank()) {
return FilterResult.keywordFilterFailed("市场标题为空,无法进行关键字过滤")
}
// 解析关键字列表
val keywords = jsonUtils.parseStringArray(copyTrading.keywords)
if (keywords.isEmpty()) {
// 如果关键字列表为空,白名单模式不通过,黑名单模式通过
return if (copyTrading.keywordFilterMode == "WHITELIST") {
FilterResult.keywordFilterFailed("白名单模式但关键字列表为空")
} else {
FilterResult.passed()
}
}
// 将市场标题转换为小写,用于不区分大小写的匹配
val titleLower = marketTitle.lowercase()
// 检查市场标题是否包含任意关键字
val containsKeyword = keywords.any { keyword ->
titleLower.contains(keyword.lowercase())
}
// 根据过滤模式决定是否通过
return when (copyTrading.keywordFilterMode) {
"WHITELIST" -> {
if (containsKeyword) {
FilterResult.passed()
} else {
FilterResult.keywordFilterFailed("白名单模式:市场标题不包含任何关键字。市场标题:$marketTitle,关键字列表:${keywords.joinToString(", ")}")
}
}
"BLACKLIST" -> {
if (containsKeyword) {
FilterResult.keywordFilterFailed("黑名单模式:市场标题包含关键字。市场标题:$marketTitle,匹配的关键字:${keywords.filter { titleLower.contains(it.lowercase()) }.joinToString(", ")}")
} else {
FilterResult.passed()
}
}
else -> FilterResult.passed()
}
}
/**
* 检查价格区间
* @param copyTrading 跟单配置
@@ -287,5 +368,38 @@ class CopyTradingFilterService(
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
}
}
/**
* 检查市场截止时间
* @param copyTrading 跟单配置
* @param marketEndDate 市场截止时间(毫秒时间戳)
* @return 过滤结果
*/
private fun checkMarketEndDate(
copyTrading: CopyTrading,
marketEndDate: Long?
): FilterResult {
// 如果未配置市场截止时间限制,直接通过
if (copyTrading.maxMarketEndDate == null) {
return FilterResult.passed()
}
// 如果没有市场截止时间,无法检查,为了安全起见,不通过
if (marketEndDate == null) {
return FilterResult.marketEndDateFailed("市场缺少截止时间信息,无法进行市场截止时间检查")
}
// 检查:市场截止时间 - 当前时间 <= 最大限制时间
val currentTime = System.currentTimeMillis()
val remainingTime = marketEndDate - currentTime
if (remainingTime > copyTrading.maxMarketEndDate) {
return FilterResult.marketEndDateFailed(
"市场截止时间超出限制: 剩余时间=${remainingTime}ms (${remainingTime / (1000 * 60 * 60)}小时) > 最大限制=${copyTrading.maxMarketEndDate}ms (${copyTrading.maxMarketEndDate / (1000 * 60 * 60)}小时)"
)
}
return FilterResult.passed()
}
}
@@ -9,6 +9,8 @@ import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
import com.google.gson.Gson
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -24,7 +26,9 @@ class CopyTradingService(
private val accountRepository: AccountRepository,
private val templateRepository: CopyTradingTemplateRepository,
private val leaderRepository: LeaderRepository,
private val monitorService: CopyTradingMonitorService
private val monitorService: CopyTradingMonitorService,
private val jsonUtils: JsonUtils,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
@@ -46,16 +50,7 @@ class CopyTradingService(
val leader = leaderRepository.findById(request.leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// 3. 检查是否已存在相同的跟单关系(accountId + leaderId
val existing = copyTradingRepository.findByAccountIdAndLeaderId(
request.accountId,
request.leaderId
)
if (existing != null) {
return Result.failure(IllegalArgumentException("该跟单关系已存在"))
}
// 4. 验证配置名(强校验:不能为空字符串)
// 3. 验证配置名(强校验:不能为空字符串
val configName = request.configName?.trim()
if (configName.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("配置名不能为空"))
@@ -88,7 +83,10 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate
)
} else {
// 手动输入(所有字段必须提供)
@@ -116,7 +114,10 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal(),
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate
)
}
@@ -145,8 +146,11 @@ class CopyTradingService(
maxPrice = config.maxPrice,
maxPositionValue = config.maxPositionValue,
maxPositionCount = config.maxPositionCount,
keywordFilterMode = config.keywordFilterMode,
keywords = config.keywords,
configName = configName,
pushFailedOrders = request.pushFailedOrders ?: false
pushFailedOrders = request.pushFailedOrders ?: false,
maxMarketEndDate = config.maxMarketEndDate
)
val saved = copyTradingRepository.save(copyTrading)
@@ -213,8 +217,17 @@ class CopyTradingService(
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue,
maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
keywords = if (request.keywords != null) {
convertKeywordsToJson(request.keywords)
} else if (request.keywordFilterMode != null && request.keywordFilterMode == "DISABLED") {
null
} else {
copyTrading.keywords
},
configName = configName,
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
maxMarketEndDate = request.maxMarketEndDate ?: copyTrading.maxMarketEndDate,
updatedAt = System.currentTimeMillis()
)
@@ -264,11 +277,10 @@ class CopyTradingService(
return try {
val copyTradings = when {
request.accountId != null && request.leaderId != null -> {
val found = copyTradingRepository.findByAccountIdAndLeaderId(
copyTradingRepository.findByAccountIdAndLeaderId(
request.accountId,
request.leaderId
)
if (found != null) listOf(found) else emptyList()
}
request.accountId != null -> {
copyTradingRepository.findByAccountId(request.accountId)
@@ -424,13 +436,46 @@ class CopyTradingService(
maxPrice = copyTrading.maxPrice?.toPlainString(),
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
maxPositionCount = copyTrading.maxPositionCount,
keywordFilterMode = copyTrading.keywordFilterMode,
keywords = convertJsonToKeywords(copyTrading.keywords),
configName = copyTrading.configName,
pushFailedOrders = copyTrading.pushFailedOrders,
maxMarketEndDate = copyTrading.maxMarketEndDate,
createdAt = copyTrading.createdAt,
updatedAt = copyTrading.updatedAt
)
}
/**
* 将关键字列表转换为 JSON 字符串
*/
private fun convertKeywordsToJson(keywords: List<String>?): String? {
if (keywords == null || keywords.isEmpty()) {
return null
}
return try {
gson.toJson(keywords)
} catch (e: Exception) {
logger.error("转换关键字列表为 JSON 失败", e)
null
}
}
/**
* 将 JSON 字符串转换为关键字列表
*/
private fun convertJsonToKeywords(jsonString: String?): List<String>? {
if (jsonString.isNullOrBlank()) {
return null
}
return try {
jsonUtils.parseStringArray(jsonString)
} catch (e: Exception) {
logger.error("解析关键字 JSON 失败", e)
null
}
}
/**
* 内部配置类(用于构建 CopyTrading 实体)
*/
@@ -454,6 +499,9 @@ class CopyTradingService(
val minPrice: BigDecimal?,
val maxPrice: BigDecimal?,
val maxPositionValue: BigDecimal?,
val maxPositionCount: Int?
val maxPositionCount: Int?,
val keywordFilterMode: String,
val keywords: String?, // JSON 字符串
val maxMarketEndDate: Long? // 市场截止时间限制(毫秒时间戳)
)
}
@@ -21,7 +21,11 @@ enum class FilterStatus {
/** 失败:超过最大仓位金额 */
FAILED_MAX_POSITION_VALUE,
/** 失败:超过最大仓位数量 */
FAILED_MAX_POSITION_COUNT
FAILED_MAX_POSITION_COUNT,
/** 失败:关键字过滤 */
FAILED_KEYWORD_FILTER,
/** 失败:市场截止时间超出限制 */
FAILED_MARKET_END_DATE
}
/**
@@ -89,6 +93,18 @@ data class FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
reason = reason
)
/** 关键字过滤失败 */
fun keywordFilterFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_KEYWORD_FILTER,
reason = reason
)
/** 市场截止时间超出限制 */
fun marketEndDateFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_MARKET_END_DATE,
reason = reason
)
}
}
@@ -14,7 +14,9 @@ import org.springframework.stereotype.Service
/**
* 跟单监听服务(主服务)
* 管理所有Leader的交易监听
* 使用链上 WebSocket 监听 Leader 的交易(实时,秒级延迟)
* 使用双重监听机制:
* 1. Activity WebSocket - 低延迟(< 100ms),优先使用
* 2. OnChain WebSocket - 高可靠性(~2-3s),作为兜底
* 同时监听跟单账户的卖出/赎回事件(通过链上 WebSocket
*/
@Service
@@ -22,6 +24,7 @@ class CopyTradingMonitorService(
private val copyTradingRepository: CopyTradingRepository,
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val activityWsService: PolymarketActivityWsService,
private val onChainWsService: OnChainWsService,
private val accountOnChainMonitorService: AccountOnChainMonitorService
) {
@@ -50,14 +53,17 @@ class CopyTradingMonitorService(
@PreDestroy
fun destroy() {
scope.cancel()
// 停止链上 WS 监听
// 停止所有监听
activityWsService.stop()
onChainWsService.stop()
accountOnChainMonitorService.stop()
}
/**
* 启动监听
* 启动链上 WebSocket 监听 Leader 的交易(实时,秒级延迟)
* 启动双重监听机制:
* 1. Activity WebSocket - 低延迟(< 100ms),优先使用
* 2. OnChain WebSocket - 高可靠性(~2-3s),作为兜底
* 同时启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
*/
suspend fun startMonitoring() {
@@ -80,10 +86,13 @@ class CopyTradingMonitorService(
accountRepository.findById(accountId).orElse(null)
}
// 4. 启动链上 WebSocket 监听 Leader 的交易(实时,秒级延迟)
// 4. 启动 Activity WebSocket 监听(优先,低延迟)
activityWsService.start(leaders)
// 5. 启动链上 WebSocket 监听(兜底,高可靠性)
onChainWsService.start(leaders)
// 5. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
// 6. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
accountOnChainMonitorService.start(accounts)
}
@@ -100,7 +109,8 @@ class CopyTradingMonitorService(
return
}
// 添加到链上 WS 监听(如果不在列表中才添加)
// 同时添加到两种监听
activityWsService.addLeader(leader)
onChainWsService.addLeader(leader)
}
@@ -115,7 +125,8 @@ class CopyTradingMonitorService(
return
}
// 没有启用的跟单配置了,移除监听
// 没有启用的跟单配置了,同时从两种监听移除
activityWsService.removeLeader(leaderId)
onChainWsService.removeLeader(leaderId)
}
@@ -130,6 +141,7 @@ class CopyTradingMonitorService(
if (copyTradings.isNotEmpty()) {
// 有启用的跟单配置,确保在监听列表中
activityWsService.addLeader(leader)
onChainWsService.addLeader(leader)
// 更新账户监听(添加该配置关联的账户)
@@ -141,7 +153,8 @@ class CopyTradingMonitorService(
}
}
} else {
// 没有启用的跟单配置,移除监听
// 没有启用的跟单配置,同时从两种监听移除
activityWsService.removeLeader(leaderId)
onChainWsService.removeLeader(leaderId)
}
}
@@ -171,6 +184,7 @@ class CopyTradingMonitorService(
*/
suspend fun restartMonitoring() {
// 停止所有监听
activityWsService.stop()
onChainWsService.stop()
delay(1000) // 等待1秒
startMonitoring()
@@ -0,0 +1,412 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
import com.wrbug.polymarketbot.dto.ActivityTradePayload
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
/**
* Polymarket Activity WebSocket 监听服务
* 通过订阅全局 activity 交易流,客户端过滤 Leader 地址,实现实时交易检测
* 延迟 < 100ms,适合快速跟单场景
*/
@Service
class PolymarketActivityWsService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val leaderRepository: LeaderRepository
) {
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
@Value("\${polymarket.websocket.activity.url:wss://ws-live-data.polymarket.com}")
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 单例 WebSocket 客户端
private var wsClient: PolymarketWebSocketClient? = null
// 要监听的 Leader 地址集合(小写地址 -> leaderId
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
// 是否已订阅
@Volatile
private var isSubscribed = false
/**
* 启动监听
*/
fun start(leaders: List<Leader>) {
monitoredAddresses.clear()
leaders.forEach { leader ->
val leaderId = leader.id
if (leaderId != null) {
monitoredAddresses[leader.leaderAddress.lowercase()] = leaderId
}
}
if (monitoredAddresses.isEmpty()) {
logger.info("没有需要监听的 Leader,停止 Activity WebSocket")
stop()
return
}
logger.info("启动 Activity WebSocket 监听,监控 ${monitoredAddresses.size} 个 Leader 地址")
connectAndSubscribe()
}
/**
* 添加 Leader
*/
fun addLeader(leader: Leader) {
if (leader.id == null) {
logger.warn("Leader ID 为空,跳过: ${leader.leaderAddress}")
return
}
val address = leader.leaderAddress.lowercase()
val existingLeaderId = monitoredAddresses[address]
if (existingLeaderId != null && existingLeaderId == leader.id) {
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${address})")
return
}
val leaderId = leader.id
if (leaderId != null) {
monitoredAddresses[address] = leaderId
logger.info("添加 Leader 到 Activity WS 监听: ${leader.leaderName} (${address})")
// 如果 WebSocket 未连接,连接
val client = wsClient
if (client == null || !client.isConnected()) {
connectAndSubscribe()
}
}
}
/**
* 移除 Leader
*/
fun removeLeader(leaderId: Long) {
val addressToRemove = monitoredAddresses.entries
.find { it.value == leaderId }?.key
if (addressToRemove != null) {
monitoredAddresses.remove(addressToRemove)
logger.info("从 Activity WS 监听移除 Leader: leaderId=$leaderId, address=$addressToRemove")
}
// 如果没有 Leader 了,停止监听
if (monitoredAddresses.isEmpty()) {
logger.info("没有 Leader 需要监听了,停止 Activity WebSocket")
stop()
}
}
/**
* 连接并订阅
*/
private fun connectAndSubscribe() {
val existingClient = wsClient
if (existingClient != null && existingClient.isConnected()) {
// 如果已连接但未订阅,发送订阅
if (!isSubscribed) {
subscribeAllActivity()
}
return
}
logger.info("连接 Activity WebSocket: $websocketUrl")
val newClient = PolymarketWebSocketClient(
url = websocketUrl,
sessionId = "copy-trading-activity",
onMessage = { message -> handleMessage(message) },
onOpen = {
logger.info("Activity WebSocket 连接成功")
subscribeAllActivity()
},
onReconnect = {
logger.info("Activity WebSocket 重连成功,重新订阅")
subscribeAllActivity()
}
)
wsClient = newClient
scope.launch {
try {
newClient.connect()
} catch (e: Exception) {
logger.error("连接 Activity WebSocket 失败", e)
}
}
}
/**
* 订阅全局 activity
* 根据 @polymarket/real-time-data-client 的协议格式
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
*/
private fun subscribeAllActivity() {
val client = wsClient
if (client == null || !client.isConnected()) {
logger.warn("WebSocket 未连接,无法订阅")
return
}
try {
// 根据 real-time-data-client 的协议格式
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
val subscribeMessage = """
{
"action": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
""".trimIndent()
client.sendMessage(subscribeMessage)
isSubscribed = true
logger.info("Activity WebSocket 订阅成功(全局交易流)")
} catch (e: Exception) {
logger.error("订阅 Activity WebSocket 失败", e)
isSubscribed = false
}
}
/**
* 处理消息
*/
private fun handleMessage(message: String) {
try {
// 处理 PONG 响应
if (message.trim() == "PONG" || message.trim() == "pong") {
return
}
// 使用扩展函数解析消息
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
// 不是有效的 JSON 或格式不匹配,跳过
logger.warn("无法解析为 ActivityTradeMessage,可能不是 activity 消息: ${message.take(200)}")
return
}
// 检查是否是 activity trade 消息
if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") {
// 不是我们关心的消息,直接返回
return
}
val payload = tradeMessage.payload
// 提取交易者地址
val traderAddress = extractTraderAddress(payload) ?: run {
// 没有交易者地址,跳过
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
return
}
// 检查是否是我们监听的 Leader
val normalizedAddress = traderAddress.lowercase()
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
return
}
// 解析交易数据
val trade = parseActivityTrade(payload, leaderId)
if (trade != null) {
logger.info("✅ 检测到 Leader 交易: leaderId=$leaderId, address=$traderAddress, side=${trade.side}, market=${trade.market}, size=${trade.size}")
// 异步处理交易(避免阻塞消息处理)
scope.launch {
try {
copyOrderTrackingService.processTrade(
leaderId = leaderId,
trade = trade,
source = "activity-ws"
)
} catch (e: Exception) {
logger.error("处理 Activity WS 交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
}
}
} else {
logger.warn("解析交易数据失败: leaderId=$leaderId, address=$traderAddress, asset=${payload.asset}, side=${payload.side}")
}
} catch (e: Exception) {
logger.error("处理 Activity WebSocket 消息失败: ${e.message}", e)
}
}
/**
* 提取交易者地址
* 优先检查 trader.addressfallback 到 proxyWallet
*/
private fun extractTraderAddress(payload: ActivityTradePayload): String? {
// 优先检查 trader.address
val address = payload.trader?.address
?: payload.proxyWallet
return address
}
/**
* 解析 Activity Trade 为 TradeResponse
*/
private fun parseActivityTrade(payload: ActivityTradePayload, leaderId: Long): TradeResponse? {
return try {
// 提取必需字段并验证
val asset = payload.asset
val conditionId = payload.conditionId
val sideRaw = payload.side
if (asset.isBlank() || conditionId.isBlank() || sideRaw.isBlank()) {
logger.warn("Activity Trade 消息缺少必需字段: asset=$asset, conditionId=$conditionId, side=$sideRaw")
return null
}
val side = sideRaw.uppercase()
// 验证 side 必须是 BUY 或 SELL
if (side != "BUY" && side != "SELL") {
logger.warn("Activity Trade 消息 side 字段无效: side=$side")
return null
}
// price 和 size 可能是数字或字符串,统一转换为字符串
val price = convertToString(payload.price) ?: run {
logger.warn("Activity Trade 消息 price 字段无效: ${payload.price}")
return null
}
val size = convertToString(payload.size) ?: run {
logger.warn("Activity Trade 消息 size 字段无效: ${payload.size}")
return null
}
// 时间戳处理:可能是秒或毫秒,可能是数字或字符串
val timestamp = when {
payload.timestamp == null -> System.currentTimeMillis().toString()
payload.timestamp is Number -> {
val ts = (payload.timestamp as Number).toLong()
// 如果时间戳小于 1e12(秒级),转换为毫秒
if (ts < 1e12) (ts * 1000).toString() else ts.toString()
}
payload.timestamp is String -> {
val tsStr = payload.timestamp as String
val ts = tsStr.toLongOrNull() ?: System.currentTimeMillis()
if (ts < 1e12) (ts * 1000).toString() else tsStr
}
else -> System.currentTimeMillis().toString()
}
// 解析 outcome 和 outcomeIndex
// 优先使用消息中的 outcomeIndex,如果没有则从 outcome 字符串解析
val outcome = payload.outcome
val outcomeIndex = payload.outcomeIndex
?: parseOutcomeIndex(outcome)
// 使用 transactionHash 作为 trade ID,如果没有则生成 fallback ID
val tradeId = payload.transactionHash ?: "${leaderId}_${System.currentTimeMillis()}_${asset.take(10)}"
TradeResponse(
id = tradeId,
market = conditionId,
side = side,
price = price,
size = size,
timestamp = timestamp,
user = null, // Activity WS 中不需要
outcomeIndex = outcomeIndex,
outcome = outcome
)
} catch (e: Exception) {
logger.error("解析 Activity Trade 失败: ${e.message}", e)
null
}
}
/**
* 将 Any 类型的值转换为 String
* 支持 Number、String、BigDecimal 等类型
*/
private fun convertToString(value: Any?): String? {
if (value == null) return null
return when (value) {
is String -> value
is Number -> {
// 如果是数字,转换为 BigDecimal 再转为字符串(保持精度)
try {
BigDecimal(value.toString()).toPlainString()
} catch (e: Exception) {
value.toString()
}
}
is BigDecimal -> value.toPlainString()
else -> value.toString()
}
}
/**
* 解析 outcome 为 outcomeIndex
*/
private fun parseOutcomeIndex(outcome: String?): Int? {
return when (outcome?.uppercase()) {
"YES", "UP", "TRUE" -> 0
"NO", "DOWN", "FALSE" -> 1
else -> null
}
}
/**
* 停止监听
*/
fun stop() {
logger.info("停止 Activity WebSocket 监听")
wsClient?.closeConnection()
wsClient = null
isSubscribed = false
monitoredAddresses.clear()
}
/**
* 获取连接状态
*/
fun isConnected(): Boolean {
return wsClient?.isConnected() ?: false
}
/**
* 获取监听的 Leader 数量
*/
fun getMonitoredCount(): Int {
return monitoredAddresses.size
}
@PreDestroy
fun destroy() {
stop()
scope.cancel()
}
}
@@ -1,7 +1,6 @@
package com.wrbug.polymarketbot.service.copytrading.orders
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.api.MarketResponse
import com.wrbug.polymarketbot.dto.OrderDetailDto
import com.wrbug.polymarketbot.dto.OrderMessageDto
import com.wrbug.polymarketbot.dto.OrderPushMessage
@@ -19,6 +18,7 @@ import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.common.MarketService
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
@@ -35,7 +35,8 @@ class OrderPushService(
private val cryptoUtils: CryptoUtils,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository? = null, // 可选,避免循环依赖
private val copyTradingRepository: CopyTradingRepository? = null, // 可选,避免循环依赖
private val leaderRepository: LeaderRepository? = null // 可选,避免循环依赖
private val leaderRepository: LeaderRepository? = null, // 可选,避免循环依赖
private val marketService: MarketService // 市场信息服务
) {
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
@@ -422,8 +423,8 @@ class OrderPushService(
result.fold(
onSuccess = { openOrder ->
// 获取市场信息(通过 Gamma API
val marketInfo = fetchMarketInfo(conditionId ?: openOrder.market)
// 获取市场信息(使用 MarketService,优先从数据库/缓存获取
val market = marketService.getMarket(conditionId ?: openOrder.market)
// 转换为 DTO
// 注意:createdAt 是 unix timestamp (Long),需要转换为字符串
@@ -436,9 +437,9 @@ class OrderPushService(
filled = openOrder.sizeMatched, // 使用 size_matched
status = openOrder.status,
createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串
marketName = marketInfo?.question,
marketSlug = marketInfo?.slug,
marketIcon = marketInfo?.icon
marketName = market?.title,
marketSlug = market?.slug, // 显示用的 slug
marketIcon = market?.icon
)
},
onFailure = { e ->
@@ -452,40 +453,6 @@ class OrderPushService(
}
}
/**
* 获取市场信息(通过 Gamma API)
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
*
* 使用 /markets 接口,通过 condition_ids 查询参数获取市场信息
* 订单返回的 market 字段是 16 进制的 condition ID(如 "0x..."
*/
private suspend fun fetchMarketInfo(conditionId: String): MarketResponse? {
return try {
// 创建 Gamma API 客户端(公开 API,不需要认证)
val gammaApi = retrofitFactory.createGammaApi()
// 调用 Gamma API 获取市场信息
// 使用 /markets 接口,通过 condition_ids 查询参数
val response = gammaApi.listMarkets(
conditionIds = listOf(conditionId),
includeTag = null
)
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
if (markets.isNotEmpty()) {
val market = markets.first()
return market
} else {
return null
}
} else {
null
}
} catch (e: Exception) {
null
}
}
/**
* 订阅账户的订单推送(保留用于向后兼容)
*/
@@ -19,6 +19,7 @@ import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterServ
import com.wrbug.polymarketbot.service.copytrading.configs.FilterStatus
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.util.CryptoUtils
@@ -48,6 +49,7 @@ open class CopyOrderTrackingService(
private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory,
private val cryptoUtils: CryptoUtils,
private val marketService: MarketService, // 市场信息服务
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
) {
@@ -122,13 +124,14 @@ open class CopyOrderTrackingService(
suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
// 获取该交易的 Mutex(按交易ID锁定,不同交易可以并行处理)
val mutex = getMutex(leaderId, trade.id)
logger.debug("processTrade: ${trade.id}, $source")
return mutex.withLock {
try {
// 1. 检查是否已处理(去重,包括失败状态)
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingProcessed != null) {
logger.debug("processTrade: 重复 ${trade.id}, $source")
if (existingProcessed.status == "FAILED") {
return@withLock Result.success(Unit)
}
@@ -264,16 +267,36 @@ open class CopyOrderTrackingService(
// 计算跟单金额(USDC)= 买入数量 × 价格
val copyOrderAmount = buyQuantity.multi(tradePrice)
// 如果启用了关键字过滤或市场截止时间过滤,需要先获取市场信息
var marketTitle: String? = null
var marketEndDate: Long? = null
val needMarketInfo =
copyTrading.keywordFilterMode != "DISABLED" || copyTrading.maxMarketEndDate != null
if (needMarketInfo) {
try {
val market = marketService.getMarket(trade.market)
marketTitle = market?.title
marketEndDate = market?.endDate
} catch (e: Exception) {
logger.warn("获取市场信息失败(关键字过滤/市场截止时间检查需要): ${e.message}", e)
}
}
// 过滤条件检查(在计算订单参数之前)
// 传入 Leader 交易价格,用于价格区间检查
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
// 传入市场标题,用于关键字过滤
// 传入市场截止时间,用于市场截止时间检查
// 订单簿只请求一次,返回给后续逻辑使用
val filterResult = filterService.checkFilters(
copyTrading,
tokenId,
tradePrice = tradePrice,
copyOrderAmount = copyOrderAmount,
marketId = trade.market
marketId = trade.market,
marketTitle = marketTitle,
marketEndDate = marketEndDate
)
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
if (!filterResult.isPassed) {
@@ -283,23 +306,9 @@ open class CopyOrderTrackingService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: trade.market
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(trade.market)
val marketTitle = market?.title ?: trade.market
val marketSlug = market?.slug // 显示用的 slug
// 从过滤结果中提取 filterType
val filterType = extractFilterType(filterResult.status, filterResult.reason)
@@ -554,24 +563,9 @@ open class CopyOrderTrackingService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse =
gammaApi.listMarkets(conditionIds = listOf(trade.market))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: trade.market
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(trade.market)
val marketTitle = market?.title ?: trade.market
val marketSlug = market?.eventSlug // 跳转用的 slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
@@ -1416,6 +1410,8 @@ open class CopyOrderTrackingService(
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"
}
}
@@ -32,7 +32,8 @@ class CopyTradingStatisticsService(
private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository,
private val accountService: AccountService,
private val blockchainService: BlockchainService
private val blockchainService: BlockchainService,
private val marketService: com.wrbug.polymarketbot.service.common.MarketService
) {
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
@@ -165,13 +166,22 @@ class CopyTradingStatisticsService(
val end = minOf(start + limit, orders.size)
val pagedOrders = if (start < orders.size) orders.subList(start, end) else emptyList()
// 批量获取市场信息
val marketIds = pagedOrders.map { it.marketId }.distinct()
val markets = marketService.getMarkets(marketIds)
// 转换为DTO
val list = pagedOrders.map { order ->
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
val market = markets[order.marketId]
BuyOrderInfo(
orderId = order.buyOrderId,
leaderTradeId = order.leaderBuyTradeId,
marketId = order.marketId,
marketTitle = market?.title,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
side = order.side,
quantity = order.quantity.toString(),
price = order.price.toString(),
@@ -212,13 +222,22 @@ class CopyTradingStatisticsService(
val end = minOf(start + limit, records.size)
val pagedRecords = if (start < records.size) records.subList(start, end) else emptyList()
// 批量获取市场信息
val marketIds = pagedRecords.map { it.marketId }.distinct()
val markets = marketService.getMarkets(marketIds)
// 转换为DTO
val list = pagedRecords.map { record ->
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
val market = markets[record.marketId]
SellOrderInfo(
orderId = record.sellOrderId,
leaderTradeId = record.leaderSellTradeId,
marketId = record.marketId,
marketTitle = market?.title,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
side = record.side,
quantity = record.totalMatchedQuantity.toString(),
price = record.sellPrice.toString(),
@@ -263,11 +282,26 @@ class CopyTradingStatisticsService(
val end = minOf(start + limit, filtered.size)
val pagedDetails = if (start < filtered.size) filtered.subList(start, end) else emptyList()
// 获取匹配记录以获取市场ID
val matchRecordIds = pagedDetails.map { it.matchRecordId }.distinct()
val matchRecords = matchRecordIds.mapNotNull { id ->
sellMatchRecordRepository.findById(id).orElse(null)
}
val marketIds = matchRecords.map { it.marketId }.distinct()
val markets = marketService.getMarkets(marketIds)
// 转换为DTO
val list = pagedDetails.map { detail ->
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
val market = matchRecord?.let { markets[it.marketId] }
MatchedOrderInfo(
sellOrderId = sellMatchRecordRepository.findById(detail.matchRecordId).orElse(null)?.sellOrderId ?: "",
sellOrderId = matchRecord?.sellOrderId ?: "",
buyOrderId = detail.buyOrderId,
marketId = matchRecord?.marketId,
marketTitle = market?.title,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
matchedQuantity = detail.matchedQuantity.toString(),
buyPrice = detail.buyPrice.toString(),
sellPrice = detail.sellPrice.toString(),
@@ -696,5 +730,220 @@ class CopyTradingStatisticsService(
val currentPositionQuantity: String,
val totalRealizedPnl: String
)
/**
* 获取按市场分组的买入订单列表
*/
fun getBuyOrderListGroupedByMarket(request: MarketGroupedOrdersRequest): Result<MarketGroupedOrdersResponse> {
return try {
// 1. 验证跟单关系
copyTradingRepository.findById(request.copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
// 2. 获取所有买入订单
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
// 3. 按市场ID分组
val groups = mutableMapOf<String, MutableList<CopyOrderTracking>>()
orders.forEach { order ->
val marketId = order.marketId
if (!groups.containsKey(marketId)) {
groups[marketId] = mutableListOf()
}
groups[marketId]!!.add(order)
}
// 4. 转换为分组数据并计算统计信息
val marketIds = groups.keys.toList()
val markets = marketService.getMarkets(marketIds)
val list = marketIds.map { marketId ->
val marketOrders = groups[marketId] ?: mutableListOf()
// 计算统计信息
val count = marketOrders.size.toLong()
val totalAmount = marketOrders.sumOf { order ->
order.quantity.toSafeBigDecimal().multi(order.price)
}
// 计算订单状态统计
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
val filledCount = marketOrders.count { it.status == "filled" }
val fullyMatched = fullyMatchedCount == marketOrders.size
val stats = MarketOrderStats(
count = count,
totalAmount = totalAmount.toString(),
totalPnl = null, // 买入订单没有已实现盈亏
fullyMatched = fullyMatched,
fullyMatchedCount = fullyMatchedCount.toLong(),
partiallyMatchedCount = partiallyMatchedCount.toLong(),
filledCount = filledCount.toLong()
)
// 排序(按创建时间倒序)
marketOrders.sortByDescending { it.createdAt }
// 转换为 DTO
val orderDtos = marketOrders.map { order ->
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
val market = markets[order.marketId]
BuyOrderInfo(
orderId = order.buyOrderId,
leaderTradeId = order.leaderBuyTradeId,
marketId = order.marketId,
marketTitle = market?.title,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
side = order.side,
quantity = order.quantity.toString(),
price = order.price.toString(),
amount = amount.toString(),
matchedQuantity = order.matchedQuantity.toString(),
remainingQuantity = order.remainingQuantity.toString(),
status = order.status,
createdAt = order.createdAt
)
}
MarketOrderGroup(
marketId = marketId,
marketTitle = markets[marketId]?.title,
marketSlug = markets[marketId]?.slug, // 显示用的 slug
eventSlug = markets[marketId]?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = markets[marketId]?.category,
stats = stats,
orders = orderDtos as List<Any>
)
}.sortedByDescending { it.stats.count }
// 5. 分页
val page = (request.page ?: 1)
val limit = request.limit ?: 20
val total = list.size.toLong()
val start = (page - 1) * limit
val end = minOf(start + limit, list.size)
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
val response = MarketGroupedOrdersResponse(
list = pagedList,
total = total,
page = page,
limit = limit
)
Result.success(response)
} catch (e: Exception) {
logger.error("获取按市场分组的买入订单列表失败: copyTradingId=${request.copyTradingId}", e)
Result.failure(e)
}
}
/**
* 获取按市场分组的卖出订单列表
*/
fun getSellOrderListGroupedByMarket(request: MarketGroupedOrdersRequest): Result<MarketGroupedOrdersResponse> {
return try {
// 1. 验证跟单关系
copyTradingRepository.findById(request.copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
// 2. 获取所有卖出记录
val sellRecords = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
// 3. 按市场ID分组
val groups = mutableMapOf<String, MutableList<SellMatchRecord>>()
sellRecords.forEach { record ->
val marketId = record.marketId
if (!groups.containsKey(marketId)) {
groups[marketId] = mutableListOf()
}
groups[marketId]!!.add(record)
}
// 4. 转换为分组数据并计算统计信息
val marketIds = groups.keys.toList()
val markets = marketService.getMarkets(marketIds)
val list = marketIds.map { marketId ->
val marketRecords = groups[marketId] ?: mutableListOf()
// 计算统计信息
val count = marketRecords.size.toLong()
val totalAmount = marketRecords.sumOf { record ->
record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
}
val totalPnl = marketRecords.sumOf { it.totalRealizedPnl.toSafeBigDecimal() }
val stats = MarketOrderStats(
count = count,
totalAmount = totalAmount.toString(),
totalPnl = totalPnl.toString(),
fullyMatched = true, // 卖出订单都是已成交的
fullyMatchedCount = count, // 所有订单都是已成交的
partiallyMatchedCount = 0L,
filledCount = 0L
)
// 排序(按创建时间倒序)
marketRecords.sortByDescending { it.createdAt }
// 转换为 DTO
val orderDtos = marketRecords.map { record ->
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
val market = markets[record.marketId]
SellOrderInfo(
orderId = record.sellOrderId,
leaderTradeId = record.leaderSellTradeId,
marketId = record.marketId,
marketTitle = market?.title,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
side = record.side,
quantity = record.totalMatchedQuantity.toString(),
price = record.sellPrice.toString(),
amount = amount.toString(),
realizedPnl = record.totalRealizedPnl.toString(),
createdAt = record.createdAt
)
}
MarketOrderGroup(
marketId = marketId,
marketTitle = markets[marketId]?.title,
marketSlug = markets[marketId]?.slug, // 显示用的 slug
eventSlug = markets[marketId]?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = markets[marketId]?.category,
stats = stats,
orders = orderDtos as List<Any>
)
}.sortedByDescending { it.stats.count }
// 5. 分页
val page = (request.page ?: 1)
val limit = request.limit ?: 20
val total = list.size.toLong()
val start = (page - 1) * limit
val end = minOf(start + limit, list.size)
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
val response = MarketGroupedOrdersResponse(
list = pagedList,
total = total,
page = page,
limit = limit
)
Result.success(response)
} catch (e: Exception) {
logger.error("获取按市场分组的卖出订单列表失败: copyTradingId=${request.copyTradingId}", e)
Result.failure(e)
}
}
}
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.service.copytrading.statistics
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.entity.*
import com.wrbug.polymarketbot.repository.*
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.CryptoUtils
@@ -33,6 +34,7 @@ class OrderStatusUpdateService(
private val retrofitFactory: RetrofitFactory,
private val cryptoUtils: CryptoUtils,
private val trackingService: CopyOrderTrackingService,
private val marketService: MarketService, // 市场信息服务
private val telegramNotificationService: TelegramNotificationService?
) {
@@ -712,25 +714,10 @@ class OrderStatusUpdateService(
logger.warn("账户不存在,跳过发送通知: accountId=${order.accountId}")
return
}
// 获取市场信息
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(order.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: order.marketId
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(order.marketId)
val marketTitle = market?.title ?: order.marketId
// 获取 Leader 和跟单配置信息
val leader = leaderRepository.findById(order.leaderId).orElse(null)
@@ -761,7 +748,7 @@ class OrderStatusUpdateService(
orderId = order.buyOrderId,
marketTitle = marketTitle,
marketId = order.marketId,
marketSlug = marketSlug,
marketSlug = market?.eventSlug, // 跳转用的 slug
side = "BUY",
price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格
size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量
@@ -817,25 +804,10 @@ class OrderStatusUpdateService(
logger.warn("账户不存在,跳过发送通知: accountId=${finalCopyTrading.accountId}")
return
}
// 获取市场信息
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(record.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: record.marketId
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(record.marketId)
val marketTitle = market?.title ?: record.marketId
// 获取 Leader 和跟单配置信息
val leader = leaderRepository.findById(finalCopyTrading.leaderId).orElse(null)
@@ -866,7 +838,7 @@ class OrderStatusUpdateService(
orderId = record.sellOrderId,
marketTitle = marketTitle,
marketId = record.marketId,
marketSlug = marketSlug,
marketSlug = market?.eventSlug, // 跳转用的 slug
side = "SELL",
price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格
size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量
@@ -1,34 +1,154 @@
package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonElement
import com.google.gson.reflect.TypeToken
import jakarta.annotation.PostConstruct
import org.springframework.stereotype.Component
/**
* JSON 工具类
* 用于解析 JSON 字符串
* 用于初始化全局 Gson 实例供扩展函数使用
*
* 使用 Spring 注入的 Gson Bean已在 GsonConfig 中配置为 lenient 模式
* 初始化后设置全局 gson 实例供扩展函数使用
*/
@Component
class JsonUtils(
private val gson: Gson
private val injectedGson: Gson
) {
@PostConstruct
fun init() {
// 设置全局 Gson 实例,供扩展函数使用
_gson = injectedGson
}
/**
* 解析 JSON 字符串数组
* @param jsonString JSON 字符串 "[\"Yes\", \"No\"]"
* @return 字符串列表如果解析失败返回空列表
*/
fun parseStringArray(jsonString: String?): List<String> {
if (jsonString.isNullOrBlank()) {
return emptyList()
}
return try {
val listType = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(jsonString, listType) ?: emptyList()
return jsonString?.parseStringArray() ?: emptyList()
}
}
/**
* 全局 Gson 实例用于扩展函数
* JsonUtils 初始化时设置
*/
@Volatile
private var _gson: Gson? = null
/**
* 获取全局 Gson 实例
* 如果尚未初始化创建默认实例
*/
val gson: Gson
get() = _gson ?: GsonBuilder().setLenient().create().also { _gson = it }
// ============================================================================
// 扩展函数:JSON 序列化和反序列化
// ============================================================================
/**
* 将对象转换为 JSON 字符串
*
* @return JSON 字符串如果对象为 null 则返回空字符串
*
* @example
* ```kotlin
* val obj = MyDataClass(name = "test", value = 123)
* val json = obj.toJson() // {"name":"test","value":123}
* ```
*/
fun Any?.toJson(): String {
return if (this == null) {
""
} else {
try {
gson.toJson(this)
} catch (e: Exception) {
emptyList()
""
}
}
}
/**
* JSON 字符串解析为对象支持泛型
*
* @return 解析后的对象如果解析失败返回 null
*
* @example
* ```kotlin
* val json = "{\"name\":\"test\",\"value\":123}"
* val obj = json.fromJson<MyDataClass>() // MyDataClass(name="test", value=123)
*
* // 对于泛型类型,自动使用 TypeToken
* val json = "[\"a\", \"b\", \"c\"]"
* val list = json.fromJson<List<String>>() // ["a", "b", "c"]
* ```
*/
inline fun <reified T> String?.fromJson(): T? {
if (this.isNullOrBlank()) {
return null
}
return try {
val typeToken = object : TypeToken<T>() {}
gson.fromJson(this, typeToken.type)
} catch (e: Exception) {
null
}
}
/**
* JsonElement 解析为对象支持泛型
*
* @return 解析后的对象如果解析失败返回 null
*
* @example
* ```kotlin
* val jsonElement: JsonElement = ...
* val obj = jsonElement.fromJson<MyDataClass>()
* ```
*/
inline fun <reified T> JsonElement?.fromJson(): T? {
if (this == null) {
return null
}
return try {
val typeToken = object : TypeToken<T>() {}
gson.fromJson(this, typeToken.type)
} catch (e: Exception) {
null
}
}
/**
* 解析 JSON 字符串数组
*
* @return 字符串列表如果解析失败返回空列表
*
* @example
* ```kotlin
* val json = "[\"Yes\", \"No\"]"
* val list = json.parseStringArray() // ["Yes", "No"]
* ```
*/
fun String?.parseStringArray(): List<String> {
if (this.isNullOrBlank()) {
return emptyList()
}
return try {
val listType = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(this, listType) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
@@ -0,0 +1,20 @@
package com.wrbug.polymarketbot.util
import com.wrbug.polymarketbot.api.MarketResponse
/**
* MarketResponse 扩展函数
* events[0].slug 获取 slug用于网页跳转
*/
fun MarketResponse?.getEventSlug(): String? {
return this?.events?.firstOrNull()?.slug ?: this?.slug
}
/**
* MarketResponse 扩展函数
* 获取显示用的 slug使用原来的 slug 字段
*/
fun MarketResponse?.getDisplaySlug(): String? {
return this?.slug
}
@@ -139,7 +139,8 @@ class PolymarketWebSocketClient(
/**
* 启动 PING 保活机制
* 根据官方文档 10 秒发送一次 "PING"
* 根据 @polymarket/real-time-data-client发送小写 "ping"
* 10 秒发送一次
*/
private fun startPing() {
stopPing() // 先停止之前的 PING 任务
@@ -149,7 +150,7 @@ class PolymarketWebSocketClient(
delay(10000) // 10 秒
if (isConnected) {
try {
sendMessage("PING")
sendMessage("ping") // 使用小写,与官方客户端保持一致
} catch (e: Exception) {
logger.warn("发送 PING 失败: $sessionId, ${e.message}")
break
@@ -30,13 +30,15 @@ server.port=${SERVER_PORT:8000}
spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev}
# 日志配置(支持通过环境变量配置)
logging.level.root=${LOG_LEVEL_ROOT:INFO}
logging.level.com.wrbug.polymarketbot=${LOG_LEVEL_APP:DEBUG}
logging.level.root=${LOG_LEVEL_ROOT:WARN}
logging.level.com.wrbug.polymarketbot=${LOG_LEVEL_APP:INFO}
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
# Polymarket API 配置
polymarket.clob.base-url=https://clob.polymarket.com
polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
polymarket.websocket.url=wss://ws-live-data.polymarket.com
polymarket.websocket.activity.url=${POLYMARKET_WEBSOCKET_ACTIVITY_URL:wss://ws-live-data.polymarket.com}
polymarket.data-api.base-url=https://data-api.polymarket.com
polymarket.gamma.base-url=https://gamma-api.polymarket.com
@@ -52,6 +54,10 @@ copy.trading.polling.interval=${COPY_TRADING_POLLING_INTERVAL:2000}
# 是否启用轮询,默认true
copy.trading.polling.enabled=${COPY_TRADING_POLLING_ENABLED:true}
# 跟单监听配置
# 链上 WebSocket 重连延迟(毫秒),默认3秒
copy.trading.onchain.ws.reconnect.delay=${COPY_TRADING_ONCHAIN_WS_RECONNECT_DELAY:3000}
# WebSocket 配置
websocket.heartbeat-timeout=${WEBSOCKET_HEARTBEAT_TIMEOUT:60000}
@@ -0,0 +1,22 @@
-- ============================================
-- V19: 创建市场信息表
-- ============================================
-- 创建 markets 表
CREATE TABLE IF NOT EXISTS markets (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
market_id VARCHAR(100) NOT NULL UNIQUE COMMENT '市场IDcondition ID',
title VARCHAR(500) NOT NULL COMMENT '市场名称(question',
slug VARCHAR(200) COMMENT '市场slug',
category VARCHAR(50) COMMENT '市场分类',
icon VARCHAR(500) COMMENT '市场图标URL',
image VARCHAR(500) COMMENT '市场图片URL',
description TEXT COMMENT '市场描述',
active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否活跃',
closed BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已关闭',
archived BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已归档',
created_at BIGINT NOT NULL COMMENT '创建时间(时间戳,毫秒)',
updated_at BIGINT NOT NULL COMMENT '更新时间(时间戳,毫秒)',
INDEX idx_market_id (market_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='市场信息表';
@@ -0,0 +1,19 @@
-- ============================================
-- V20: 添加关键字过滤字段
-- 在 copy_trading 表中添加关键字过滤配置
-- 支持白名单和黑名单两种模式
-- ============================================
-- 添加关键字过滤模式字段
-- DISABLED: 不启用关键字过滤(默认)
-- WHITELIST: 白名单模式,只跟单包含关键字的市场
-- BLACKLIST: 黑名单模式,不跟单包含关键字的市场
ALTER TABLE copy_trading
ADD COLUMN keyword_filter_mode VARCHAR(20) NOT NULL DEFAULT 'DISABLED' COMMENT '关键字过滤模式(DISABLED/WHITELIST/BLACKLIST' AFTER max_position_count;
-- 添加关键字列表字段(JSON 数组)
-- 当 keyword_filter_mode 为 DISABLED 时,此字段为 NULL
-- 当 keyword_filter_mode 为 WHITELIST 或 BLACKLIST 时,此字段存储关键字数组,例如:["NBA", "足球", "NBA总决赛"]
ALTER TABLE copy_trading
ADD COLUMN keywords JSON NULL COMMENT '关键字列表(JSON数组),仅在WHITELIST或BLACKLIST模式下使用' AFTER keyword_filter_mode;
@@ -0,0 +1,8 @@
-- ============================================
-- V21: 添加 event_slug 字段到 markets 表
-- 用于存储跳转用的 slug(从 events[0].slug 获取)
-- ============================================
ALTER TABLE markets
ADD COLUMN event_slug VARCHAR(200) NULL COMMENT '跳转用的 slug(从 events[0].slug 获取,用于构建 URL' AFTER slug;
@@ -0,0 +1,20 @@
-- ============================================
-- V22: 添加市场截止时间筛选功能
-- 1. 为 copy_trading 表添加 max_market_end_date 字段
-- 2. 为 markets 表添加 end_date 字段并清空已有数据
-- ============================================
-- 1. 添加市场截止时间筛选字段到 copy_trading 表
-- 仅跟单截止时间小于设置时间的订单
-- 存储毫秒时间戳,NULL 表示不启用此筛选
ALTER TABLE copy_trading
ADD COLUMN max_market_end_date BIGINT NULL COMMENT '市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用';
-- 2. 为 markets 表添加 end_date 字段
-- 删除已有数据,让系统重新从 API 获取,确保所有数据的 end_date 都正确填充
DELETE FROM markets;
-- 添加市场截止时间字段
ALTER TABLE markets
ADD COLUMN end_date BIGINT NULL COMMENT '市场截止时间(毫秒时间戳),NULL表示未设置' AFTER archived;
@@ -0,0 +1,19 @@
-- ============================================
-- V23: 移除跟单关系的唯一约束
-- 允许同一用户创建多个相同 leader 的跟单配置
-- ============================================
-- 删除唯一约束 uk_account_leader
SET @uk_name = (SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'copy_trading'
AND CONSTRAINT_TYPE = 'UNIQUE'
AND CONSTRAINT_NAME = 'uk_account_leader'
LIMIT 1);
SET @sql = IF(@uk_name IS NOT NULL,
CONCAT('ALTER TABLE copy_trading DROP INDEX ', @uk_name),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+3 -3
View File
@@ -77,10 +77,10 @@ JWT_SECRET=${JWT_SECRET}
# 管理员密码重置密钥(已自动生成随机值,生产环境建议修改)
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_KEY}
# 日志级别配置(可选,默认值:root=INFO, app=DEBUG
# 日志级别配置(可选,默认值:root=WARN, app=INFO
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
# LOG_LEVEL_ROOT=INFO
# LOG_LEVEL_APP=DEBUG
# LOG_LEVEL_ROOT=WARN
# LOG_LEVEL_APP=INFO
EOF
info ".env 文件已创建,已自动生成随机密码和密钥"
warn "生产环境建议修改以下参数:"
+17
View File
@@ -58,3 +58,20 @@ ADMIN_RESET_PASSWORD_KEY=change-me-in-production
# 生成方式 / Generate: openssl rand -hex 32
CRYPTO_SECRET_KEY=change-me-in-production
# ============================================
# 日志级别配置 / Logging Level Configuration
# ============================================
# 可选配置,如果不设置则使用默认值 / Optional configuration, uses default values if not set
#
# Root 日志级别,控制所有第三方库的日志输出
# Root logging level, controls log output for all third-party libraries
# 默认值 / Default: WARN
# 可选值 / Options: TRACE, DEBUG, INFO, WARN, ERROR, OFF
# LOG_LEVEL_ROOT=WARN
#
# 应用日志级别,控制应用代码的日志输出
# Application logging level, controls log output for application code
# 默认值 / Default: INFO
# 可选值 / Options: TRACE, DEBUG, INFO, WARN, ERROR, OFF
# LOG_LEVEL_APP=INFO
+3 -3
View File
@@ -29,10 +29,10 @@ services:
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
# 日志级别配置(可选,默认值:root=INFO, app=DEBUG
# 日志级别配置(可选,默认值:root=WARN, app=INFO
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-INFO}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-DEBUG}
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
depends_on:
mysql:
condition: service_healthy
+3 -3
View File
@@ -27,10 +27,10 @@ services:
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
# 日志级别配置(可选,默认值:root=INFO, app=DEBUG
# 日志级别配置(可选,默认值:root=WARN, app=INFO
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-INFO}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-DEBUG}
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
depends_on:
mysql:
condition: service_healthy
@@ -0,0 +1,477 @@
# 跟单双重监听方案(OnChain + Poly Activity WS
## 1. 方案概述
### 1.1 目标
实现 **OnChain WebSocket + Polymarket Activity WebSocket** 双重监听机制,提高跟单系统的实时性和可靠性。
### 1.2 架构图
```
┌─────────────────────────────────────────────────────────────────┐
│ CopyTradingMonitorService │
│ (主监听协调服务) │
└────────────┬──────────────────────────────────┬──────────────────┘
│ │
▼ ▼
┌────────────────────────┐ ┌──────────────────────────────┐
│ PolymarketActivity │ │ OnChainWsService │
│ WsService (新增) │ │ (已存在) │
│ │ │ │
│ - 订阅全局 activity │ │ - 订阅 Leader 地址的链上事件 │
│ - 客户端地址过滤 │ │ - 监听 Transfer 事件 │
│ - 延迟 < 100ms │ │ - 延迟 ~2-3s (出块时间) │
│ - 数据来源:Poly WS │ │ - 数据来源:链上 RPC │
└────────────┬───────────┘ └──────────────┬───────────────┘
│ │
└──────────────┬──────────────────────┘
┌───────────────────────┐
│ CopyOrderTrackingService│
│ (统一处理服务) │
│ │
│ - 去重(leaderId+ │
│ tradeId
│ - 处理买入/卖出 │
│ - 记录处理状态 │
└────────────────────────┘
```
### 1.3 优势对比
| 特性 | Activity WS | OnChain WS | 双重监听 |
|------|-------------|------------|----------|
| **延迟** | < 100ms | ~2-3s | < 100ms (优先) |
| **可靠性** | 中等(依赖 Poly 服务) | 高(链上数据) | 高(双重保障) |
| **数据完整性** | 完整(包含 market、price、size | 需要解析 Transfer | 完整 |
| **兜底机制** | ❌ | ✅ | ✅ |
| **去重** | 需要 | 需要 | 统一去重 |
## 2. 技术方案
### 2.1 新增服务:PolymarketActivityWsService
**职责**
- 订阅 Polymarket Activity WebSocket(全局交易流)
- 根据 Leader 地址列表进行客户端过滤
- 将交易事件转换为 `TradeResponse` 格式
- 调用 `CopyOrderTrackingService.processTrade()` 处理
**实现要点**
1. 使用单个 WebSocket 连接订阅全局 activity(不传 filters
2. 客户端维护 Leader 地址 Set,实时过滤交易
3. 支持动态添加/移除 Leader(无需重连)
4. 自动重连机制
### 2.2 修改 CopyTradingMonitorService
**职责**
- 协调两种监听服务
- 统一管理 Leader 的添加/移除
- 启动时同时启动两种监听
### 2.3 去重机制
**现有机制**CopyOrderTrackingService):
- 使用 `leaderId + trade.id` 作为唯一键
- 基于数据库表 `processed_trade` 去重
- 使用 Mutex 保证线程安全
**双重监听场景**
- Activity WS 和 OnChain WS 可能同时检测到同一笔交易
- 去重机制确保只处理一次(第一个到达的)
- 通过 `source` 字段区分数据源("activity-ws" / "onchain-ws"
## 3. 实现细节
### 3.1 PolymarketActivityWsService 设计
```kotlin
@Service
class PolymarketActivityWsService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val leaderRepository: LeaderRepository
) {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 单例 WebSocket 客户端
private var wsClient: PolymarketWebSocketClient? = null
// 要监听的 Leader 地址集合(小写)
private val monitoredAddresses = ConcurrentHashMap<String, Long>() // address -> leaderId
/**
* 启动监听
*/
fun start(leaders: List<Leader>) {
monitoredAddresses.clear()
leaders.forEach { leader ->
if (leader.id != null) {
monitoredAddresses[leader.leaderAddress.lowercase()] = leader.id!!
}
}
if (monitoredAddresses.isEmpty()) {
stop()
return
}
connectAndSubscribe()
}
/**
* 添加 Leader
*/
fun addLeader(leader: Leader) {
if (leader.id == null) return
monitoredAddresses[leader.leaderAddress.lowercase()] = leader.id!!
// 如果 WebSocket 未连接,连接
if (wsClient == null) {
connectAndSubscribe()
}
}
/**
* 移除 Leader
*/
fun removeLeader(leaderId: Long) {
val addressToRemove = monitoredAddresses.entries
.find { it.value == leaderId }?.key
addressToRemove?.let { monitoredAddresses.remove(it) }
// 如果没有 Leader 了,停止监听
if (monitoredAddresses.isEmpty()) {
stop()
}
}
/**
* 连接并订阅
*/
private fun connectAndSubscribe() {
if (wsClient != null && wsClient!!.isConnected()) {
return
}
wsClient = PolymarketWebSocketClient(
url = "wss://ws-live-data.polymarket.com",
sessionId = "copy-trading-activity",
onMessage = { message -> handleMessage(message) },
onOpen = {
subscribeAllActivity()
},
onReconnect = {
subscribeAllActivity()
}
)
scope.launch {
try {
wsClient!!.connect()
} catch (e: Exception) {
logger.error("连接 Activity WebSocket 失败", e)
}
}
}
/**
* 订阅全局 activity
*/
private fun subscribeAllActivity() {
val message = """
{
"type": "subscribe",
"channel": "activity",
"topic": "trades"
}
""".trimIndent()
wsClient?.sendMessage(message)
}
/**
* 处理消息
*/
private fun handleMessage(message: String) {
try {
if (message.trim() == "PONG") return
val json = JsonParser.parseString(message).asJsonObject
// 检查是否是 trade 事件
val topic = json.get("topic")?.asString
val type = json.get("type")?.asString
if (topic != "activity" || type != "trades") {
return
}
val payload = json.getAsJsonObject("payload") ?: return
// 提取交易者地址
val traderObj = payload.getAsJsonObject("trader")
val traderAddress = traderObj?.get("address")?.asString?.lowercase()
?: payload.get("proxyWallet")?.asString?.lowercase()
?: return
// 检查是否是我们要监听的 Leader
val leaderId = monitoredAddresses[traderAddress] ?: return
// 解析交易数据
val trade = parseActivityTrade(payload, leaderId)
if (trade != null) {
scope.launch {
copyOrderTrackingService.processTrade(
leaderId = leaderId,
trade = trade,
source = "activity-ws"
)
}
}
} catch (e: Exception) {
logger.error("处理 Activity WebSocket 消息失败", e)
}
}
/**
* 解析 Activity Trade 为 TradeResponse
*/
private fun parseActivityTrade(json: JsonObject, leaderId: Long): TradeResponse? {
return try {
TradeResponse(
id = json.get("transactionHash")?.asString
?: "${leaderId}_${System.currentTimeMillis()}", // fallback
market = json.get("conditionId")?.asString ?: return null,
side = json.get("side")?.asString?.uppercase() ?: return null,
price = json.get("price")?.asString ?: return null,
size = json.get("size")?.asString ?: return null,
timestamp = (json.get("timestamp")?.asLong ?: System.currentTimeMillis()).toString(),
user = null, // Activity WS 中不需要
outcomeIndex = parseOutcomeIndex(json.get("outcome")?.asString),
outcome = json.get("outcome")?.asString
)
} catch (e: Exception) {
logger.error("解析 Activity Trade 失败", e)
null
}
}
private fun parseOutcomeIndex(outcome: String?): Int? {
return when (outcome?.uppercase()) {
"YES", "UP", "TRUE" -> 0
"NO", "DOWN", "FALSE" -> 1
else -> null
}
}
fun stop() {
wsClient?.closeConnection()
wsClient = null
monitoredAddresses.clear()
}
}
```
### 3.2 修改 CopyTradingMonitorService
```kotlin
@Service
class CopyTradingMonitorService(
private val copyTradingRepository: CopyTradingRepository,
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val onChainWsService: OnChainWsService,
private val accountOnChainMonitorService: AccountOnChainMonitorService,
private val activityWsService: PolymarketActivityWsService // 新增
) {
suspend fun startMonitoring() {
val enabledCopyTradings = copyTradingRepository.findByEnabledTrue()
if (enabledCopyTradings.isEmpty()) {
return
}
val leaderIds = enabledCopyTradings.map { it.leaderId }.distinct()
val leaders = leaderIds.mapNotNull { leaderId ->
leaderRepository.findById(leaderId).orElse(null)
}
val accountIds = enabledCopyTradings.map { it.accountId }.distinct()
val accounts = accountIds.mapNotNull { accountId ->
accountRepository.findById(accountId).orElse(null)
}
// 1. 启动 Activity WebSocket 监听(优先,低延迟)
activityWsService.start(leaders)
// 2. 启动链上 WebSocket 监听(兜底,高可靠性)
onChainWsService.start(leaders)
// 3. 启动账户监听
accountOnChainMonitorService.start(accounts)
}
suspend fun addLeaderMonitoring(leaderId: Long) {
val leader = leaderRepository.findById(leaderId).orElse(null) ?: return
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
if (copyTradings.isEmpty()) {
return
}
// 同时添加到两种监听
activityWsService.addLeader(leader)
onChainWsService.addLeader(leader)
}
suspend fun removeLeaderMonitoring(leaderId: Long) {
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
if (copyTradings.isNotEmpty()) {
return
}
// 同时从两种监听移除
activityWsService.removeLeader(leaderId)
onChainWsService.removeLeader(leaderId)
}
@PreDestroy
fun destroy() {
scope.cancel()
activityWsService.stop()
onChainWsService.stop()
accountOnChainMonitorService.stop()
}
}
```
### 3.3 WebSocket 客户端复用
**检查现有 PolymarketWebSocketClient**
- 如果已存在,检查是否支持 Activity 订阅
- 如果不存在,需要创建或引入依赖
**依赖选项**
1. **使用现有实现**:检查 `PolymarketWebSocketClient` 是否支持 Activity
2. **引入 poly-sdk 的方式**:参考 poly-sdk 的 `RealTimeDataClient`
3. **自行实现**:基于 OkHttp WebSocket 或 Java-WebSocket
## 4. 数据流
### 4.1 Activity WS 数据流
```
Polymarket 服务器
↓ (WebSocket推送 activity trade)
PolymarketActivityWsService.handleMessage()
↓ (提取 trader.address)
客户端过滤(monitoredAddresses.contains(traderAddress)
↓ (匹配到 Leader)
parseActivityTrade() → TradeResponse
CopyOrderTrackingService.processTrade(
leaderId, trade, source="activity-ws"
)
↓ (去重检查)
如果未处理 → 执行跟单逻辑
如果已处理 → 跳过(可能是 OnChain WS 先到达)
```
### 4.2 OnChain WS 数据流(保持不变)
```
链上 RPC WebSocket
↓ (推送 Transfer 事件)
UnifiedOnChainWsService
OnChainWsService.handleLeaderTransaction()
↓ (解析 Transfer → Trade)
CopyOrderTrackingService.processTrade(
leaderId, trade, source="onchain-ws"
)
↓ (去重检查)
如果未处理 → 执行跟单逻辑
如果已处理 → 跳过(可能是 Activity WS 先到达)
```
## 5. 配置项
### 5.1 application.properties
```properties
# Polymarket WebSocket
polymarket.websocket.url=wss://ws-live-data.polymarket.com
# 监听策略
copy.trading.monitor.strategy=dual
# dual: 双重监听(Activity WS + OnChain WS
# activity-only: 仅 Activity WS
# onchain-only: 仅 OnChain WS
# Activity WS 配置
copy.trading.activity.ws.enabled=true
copy.trading.activity.ws.reconnect.delay=3000
# OnChain WS 配置(已存在)
copy.trading.onchain.ws.reconnect.delay=3000
```
## 6. 监控和日志
### 6.1 关键指标
- **Activity WS 连接状态**
- **OnChain WS 连接状态**
- **每种源的交易检测数量**
- **去重次数(重复检测)**
- **处理延迟(Activity WS vs OnChain WS**
### 6.2 日志示例
```
[INFO] Activity WS 连接成功
[INFO] Activity WS 订阅全局 activity 成功
[INFO] 检测到交易: leaderId=1, address=0x1234..., source=activity-ws, latency=85ms
[INFO] 检测到交易: leaderId=1, address=0x1234..., source=onchain-ws, latency=2150ms
[WARN] 交易已处理(去重): leaderId=1, tradeId=xxx, firstSource=activity-ws, duplicateSource=onchain-ws
```
## 7. 实施步骤
### Phase 1: 基础实现
1. ✅ 创建 `PolymarketActivityWsService`
2. ✅ 实现 Activity WebSocket 连接和订阅
3. ✅ 实现客户端地址过滤
4. ✅ 实现交易解析和转换
### Phase 2: 集成
5. ✅ 修改 `CopyTradingMonitorService` 集成 Activity WS
6. ✅ 测试双重监听和去重机制
7. ✅ 添加配置项支持
### Phase 3: 优化
8. ✅ 添加监控指标
9. ✅ 性能优化(连接池、消息批处理等)
10. ✅ 错误处理和降级策略
### Phase 4: 验证
11. ✅ 端到端测试
12. ✅ 压力测试
13. ✅ 生产环境灰度验证
## 8. 风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| Activity WS 服务不稳定 | 高 | OnChain WS 作为兜底 |
| 消息重复处理 | 中 | 数据库去重 + Mutex 锁 |
| 连接断开频繁 | 中 | 自动重连 + 指数退避 |
| 地址过滤性能 | 低 | 使用 ConcurrentHashMapO(1) 查询 |
## 9. 未来优化
1. **消息批处理**:将多个交易合并处理,减少数据库写入
2. **智能切换**:根据 Activity WS 稳定性自动降级到 OnChain
3. **延迟监控**:实时监控两种源的延迟,选择最优源
4. **缓存优化**:缓存 Leader 地址列表,减少查询
@@ -0,0 +1,389 @@
# Polymarket Activity WebSocket API 格式
## 1. 连接信息
### 1.1 WebSocket URL
```
wss://ws-live-data.polymarket.com
```
这是 Polymarket 官方 RTDS (Real-Time Data Stream) 的 WebSocket 端点。
### 1.2 协议说明
- 使用 **官方 `@polymarket/real-time-data-client`** 的协议格式
- 消息格式为 JSON
- 支持自动重连
- 需要 PING/PONG 保活(每 10 秒发送 PING
## 2. 订阅消息格式
### 2.1 订阅全局 Activity(所有交易)
**订阅消息**
```json
{
"action": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
```
**关键点**
- `action`: `"subscribe"` - 订阅动作(根据 `@polymarket/real-time-data-client` 协议)
- `topic`: `"activity"` - 活动数据频道
- `type`: `"trades"` - 交易类型(在 subscriptions 数组内)
- **不传 `filters` 字段** - 订阅所有市场的交易(空对象 `{}` 会被拒绝)
### 2.2 订阅特定市场的 Activity(可选)
如果需要过滤特定市场:
```json
{
"action": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades",
"filters": "{\"market_slug\":\"trump-win-2024\"}"
}
]
}
```
或者过滤特定事件:
```json
{
"action": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades",
"filters": "{\"event_slug\":\"presidential-election-2024\"}"
}
]
}
```
**注意**
- `filters` 是 JSON 字符串(不是对象)
- 使用 `snake_case``market_slug`, `event_slug`
- 对于 Copy Trading,我们**不传 filters**,订阅全局然后客户端过滤
### 2.3 取消订阅
```json
{
"action": "unsubscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
```
## 3. 接收消息格式
### 3.1 Trade 消息结构
当有交易发生时,服务器会推送如下格式的消息:
```json
{
"topic": "activity",
"type": "trades",
"timestamp": 1704067200000,
"payload": {
"asset": "47632033502843656213...", // Token ID (用于下单)
"conditionId": "0xb82c6573...", // Market condition ID
"eventSlug": "aus-mct-per-2025-12-28", // 事件 slug
"slug": "aus-mct-per-draw", // 市场 slug
"outcome": "No", // 结果方向 (Yes/No)
"side": "BUY", // 交易方向 (BUY/SELL)
"size": 15.72, // 交易数量 (shares)
"price": 0.87, // 交易价格
"timestamp": 1766913243, // Unix 时间戳 (秒)
"transactionHash": "0x921936dfc9...", // 交易哈希
"trader": { // 交易者信息对象
"name": "gabagool22", // 交易者用户名(可选)
"address": "0x6031B6eed1C97e..." // 交易者钱包地址 ⭐ 关键字段!
}
}
}
```
**重要字段说明**
- `payload.trader.address` - **交易者钱包地址**(用于过滤 Leader
- `payload.trader.name` - 交易者用户名(可选)
- `payload.asset` - Token ID(用于下单)
- `payload.conditionId` - Market condition ID
- `payload.side` - BUY 或 SELL
- `payload.size` - 交易数量
- `payload.price` - 交易价格
- `payload.timestamp` - Unix 时间戳(秒)
### 3.2 注意:字段命名可能不同
根据实测(poly-sdk 文档),有些情况下字段可能在顶层:
```json
{
"topic": "activity",
"type": "trades",
"payload": {
"asset": "...",
"conditionId": "...",
"side": "BUY",
"price": 0.87,
"size": 15.72,
"transactionHash": "...",
"name": "gabagool22", // 可能在顶层
"proxyWallet": "0x6031..." // 可能在顶层(而不是 trader.address
}
}
```
**建议处理方式**:同时检查 `payload.trader?.address``payload.proxyWallet`
## 4. PING/PONG 保活
### 4.1 发送 PING
每 10 秒发送一次:
```json
"PING"
```
或:
```json
{
"type": "ping"
}
```
### 4.2 接收 PONG
服务器会回复:
```json
"PONG"
```
或:
```json
{
"type": "pong"
}
```
## 5. Kotlin 实现示例
### 5.1 订阅消息
```kotlin
fun subscribeAllActivity() {
val subscribeMessage = """
{
"type": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
""".trimIndent()
wsClient.sendMessage(subscribeMessage)
}
```
### 5.2 解析交易消息
```kotlin
private fun parseActivityTrade(json: JsonObject): ActivityTrade? {
val payload = json.getAsJsonObject("payload") ?: return null
// 提取交易者地址(优先 trader.addressfallback 到 proxyWallet
val traderObj = payload.getAsJsonObject("trader")
val traderAddress = traderObj?.get("address")?.asString
?: payload.get("proxyWallet")?.asString
?: return null
val traderName = traderObj?.get("name")?.asString
?: payload.get("name")?.asString
return ActivityTrade(
asset = payload.get("asset")?.asString ?: return null,
conditionId = payload.get("conditionId")?.asString ?: return null,
eventSlug = payload.get("eventSlug")?.asString,
marketSlug = payload.get("slug")?.asString,
outcome = payload.get("outcome")?.asString,
side = payload.get("side")?.asString?.uppercase() ?: return null,
size = payload.get("size")?.asDouble ?: return null,
price = payload.get("price")?.asDouble ?: return null,
timestamp = payload.get("timestamp")?.asLong
?.let { if (it < 1e12) it * 1000 else it } // 秒转毫秒
?: System.currentTimeMillis(),
transactionHash = payload.get("transactionHash")?.asString,
traderAddress = traderAddress.lowercase(),
traderName = traderName
)
}
```
## 6. 与现有实现的对比
### 6.1 User Channel(当前使用)
**URL**: `wss://ws-subscriptions-clob.polymarket.com/ws/user`
**订阅格式**:
```json
{
"type": "subscribe",
"channel": "user",
"user": "0x1234..."
}
```
**限制**
- ❌ 只能订阅**自己的**交易(需要 API 认证)
- ❌ 无法监听别人的交易
### 6.2 Activity Channel(新方案)
**URL**: `wss://ws-live-data.polymarket.com`
**订阅格式**:
```json
{
"type": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
```
**优势**
- ✅ 可以监听**所有**交易的全局流
- ✅ 包含交易者地址,可以客户端过滤
- ✅ 不需要认证
- ✅ 延迟低(< 100ms
## 7. 完整的消息处理流程
### 7.1 连接和订阅
```kotlin
// 1. 连接到 WebSocket
val client = PolymarketWebSocketClient(
url = "wss://ws-live-data.polymarket.com",
sessionId = "copy-trading-activity",
onMessage = { message -> handleMessage(message) },
onOpen = {
subscribeAllActivity()
},
onReconnect = {
subscribeAllActivity()
}
)
client.connect()
// 2. 发送订阅消息
fun subscribeAllActivity() {
val message = """
{
"type": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
""".trimIndent()
client.sendMessage(message)
}
```
### 7.2 处理消息
```kotlin
private fun handleMessage(message: String) {
if (message.trim() == "PONG") return
val json = JsonParser.parseString(message).asJsonObject
// 检查是否是 activity trade 消息
val topic = json.get("topic")?.asString
val type = json.get("type")?.asString
if (topic == "activity" && type == "trades") {
val payload = json.getAsJsonObject("payload") ?: return
// 提取交易者地址
val traderAddress = extractTraderAddress(payload) ?: return
// 检查是否是我们监听的 Leader
val leaderId = monitoredAddresses[traderAddress.lowercase()] ?: return
// 解析交易
val trade = parseActivityTrade(payload) ?: return
// 处理交易
processTrade(leaderId, trade)
}
}
private fun extractTraderAddress(payload: JsonObject): String? {
// 优先检查 trader.address
val traderObj = payload.getAsJsonObject("trader")
val address = traderObj?.get("address")?.asString
?: payload.get("proxyWallet")?.asString
return address?.lowercase()
}
```
## 8. 错误处理
### 8.1 连接错误
- 连接失败时自动重连(指数退避:3s → 6s → 12s → 最大 60s
- 重连后重新发送订阅消息
### 8.2 订阅错误
如果订阅失败,服务器可能返回:
```json
{
"type": "error",
"message": "Invalid subscription"
}
```
### 8.3 消息解析错误
- 如果字段缺失,记录警告日志并跳过
- 如果地址格式错误,跳过该消息
- 如果解析失败,记录错误但不中断连接
## 9. 性能考虑
### 9.1 消息频率
- Activity WebSocket 会推送**所有市场**的所有交易
- 高频市场可能每秒收到数百条消息
- 需要高效的客户端过滤(使用 `Set``Map`O(1) 查找)
### 9.2 内存管理
- 维护 Leader 地址 Set(通常 < 100 个地址)
- 消息处理使用协程异步处理,避免阻塞
- 定期清理不再监听的地址
-4
View File
@@ -23,8 +23,6 @@ import TemplateList from './pages/TemplateList'
import TemplateAdd from './pages/TemplateAdd'
import TemplateEdit from './pages/TemplateEdit'
import CopyTradingList from './pages/CopyTradingList'
import CopyTradingAdd from './pages/CopyTradingAdd'
import CopyTradingEdit from './pages/CopyTradingEdit'
import CopyTradingStatistics from './pages/CopyTradingStatistics'
import CopyTradingBuyOrders from './pages/CopyTradingBuyOrders'
import CopyTradingSellOrders from './pages/CopyTradingSellOrders'
@@ -250,8 +248,6 @@ function App() {
<Route path="/templates/add" element={<ProtectedRoute><TemplateAdd /></ProtectedRoute>} />
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
<Route path="/copy-trading/add" element={<ProtectedRoute><CopyTradingAdd /></ProtectedRoute>} />
<Route path="/copy-trading/edit/:id" element={<ProtectedRoute><CopyTradingEdit /></ProtectedRoute>} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
{/* 保留旧路由以保持向后兼容 */}
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
@@ -227,8 +227,8 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
<Radio value="safe">
{t('accountImport.walletTypeSafe')}
</Radio>
<Radio value="magic">
{t('accountImport.walletTypeMagic')}
<Radio value="magic" disabled>
{t('accountImport.walletTypeMagic')} {t('accountImport.magicNotSupported')}
</Radio>
</Radio.Group>
</Form.Item>
+55 -2
View File
@@ -30,7 +30,10 @@
"total": "Total",
"items": "items",
"prev": "Previous",
"next": "Next"
"previous": "Previous",
"next": "Next",
"page": "Page",
"pageOf": "Page"
},
"account": {
"title": "Account Management",
@@ -203,7 +206,8 @@
"walletType": "Wallet Type",
"walletTypeHelp": "Web3 Wallet: Polymarket accounts connected via browser wallets like MetaMask\nMagic: Polymarket accounts logged in via email or social accounts (Google, Twitter, etc.)",
"walletTypeMagic": "Magic (Email/Social Login)",
"walletTypeSafe": "Web3 Wallet"
"walletTypeSafe": "Web3 Wallet",
"magicNotSupported": "(Not Supported)"
},
"leader": {
"title": "Leader Management",
@@ -753,6 +757,25 @@
"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.",
"disabled": "Disabled",
"whitelist": "Whitelist",
"blacklist": "Blacklist",
"keywords": "Keywords",
"keywordPlaceholder": "Enter keyword, press Enter to add",
"keywordExists": "Keyword already exists",
"whitelistTooltip": "💡 Whitelist mode: only copy markets whose titles contain any of the above keywords (case-insensitive)",
"blacklistTooltip": "💡 Blacklist mode: do not copy markets whose titles contain any of the above keywords (case-insensitive)",
"marketEndDateFilter": "Market End Date Limit",
"maxMarketEndDate": "Max Market End Date",
"maxMarketEndDateTooltip": "Only copy orders with market end time less than the set time. For example: 24 hours means only copy markets with less than 24 hours remaining until settlement",
"maxMarketEndDatePlaceholder": "Enter time value (optional)",
"timeUnit": "Unit",
"hour": "Hours",
"day": "Days",
"maxMarketEndDateNote": "💡 Note: Leave blank to disable this filter",
"configName": "Configuration Name",
"configNameRequired": "Please enter configuration name",
"configNamePlaceholder": "e.g., Copy Trading Config 1",
@@ -839,6 +862,25 @@
"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.",
"disabled": "Disabled",
"whitelist": "Whitelist",
"blacklist": "Blacklist",
"keywords": "Keywords",
"keywordPlaceholder": "Enter keyword, press Enter to add",
"keywordExists": "Keyword already exists",
"whitelistTooltip": "💡 Whitelist mode: only copy markets whose titles contain any of the above keywords (case-insensitive)",
"blacklistTooltip": "💡 Blacklist mode: do not copy markets whose titles contain any of the above keywords (case-insensitive)",
"marketEndDateFilter": "Market End Date Limit",
"maxMarketEndDate": "Max Market End Date",
"maxMarketEndDateTooltip": "Only copy orders with market end time less than the set time. For example: 24 hours means only copy markets with less than 24 hours remaining until settlement",
"maxMarketEndDatePlaceholder": "Enter time value (optional)",
"timeUnit": "Unit",
"hour": "Hours",
"day": "Days",
"maxMarketEndDateNote": "💡 Note: Leave blank to disable this filter",
"configName": "Configuration Name",
"configNameRequired": "Please enter configuration name",
"configNamePlaceholder": "e.g., Copy Trading Config 1",
@@ -883,6 +925,7 @@
"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"
@@ -1053,6 +1096,16 @@
"noBuyOrders": "No buy orders",
"noSellOrders": "No sell orders",
"noMatchedOrders": "No matched orders",
"groupByMarket": "Group by Market",
"expandAll": "Expand All",
"collapseAll": "Collapse All",
"allFullyMatched": "All Fully Matched",
"partiallyMatched": "Partially Matched",
"orderCount": "Order Count",
"totalAmount": "Total Amount",
"totalPnl": "Total PnL",
"statusBreakdown": "Status",
"markets": "markets",
"fetchBuyOrdersFailed": "Failed to fetch buy orders",
"fetchSellOrdersFailed": "Failed to fetch sell orders",
"fetchMatchedOrdersFailed": "Failed to fetch matched orders",
+53 -1
View File
@@ -27,7 +27,10 @@
"total": "共",
"items": "条",
"prev": "上一页",
"previous": "上一页",
"next": "下一页",
"page": "页",
"pageOf": "第",
"success": "成功",
"failed": "失败",
"close": "关闭"
@@ -203,7 +206,8 @@
"walletType": "钱包类型",
"walletTypeHelp": "Web3钱包:使用 MetaMask 等浏览器钱包连接的 Polymarket 账户\nMagic:通过邮箱或社交账号(如 Google、Twitter)登录的 Polymarket 账户",
"walletTypeMagic": "Magic(邮箱/社交账号登录)",
"walletTypeSafe": "Web3钱包"
"walletTypeSafe": "Web3钱包",
"magicNotSupported": "(暂不支持)"
},
"leader": {
"title": "Leader 管理",
@@ -765,6 +769,25 @@
"maxPositionCount": "最大仓位数量",
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"keywordFilter": "关键字过滤",
"keywordFilterMode": "过滤模式",
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
"disabled": "不启用",
"whitelist": "白名单",
"blacklist": "黑名单",
"keywords": "关键字",
"keywordPlaceholder": "输入关键字,按回车添加",
"keywordExists": "关键字已存在",
"whitelistTooltip": "💡 白名单模式:只跟单包含上述任意关键字的市场标题(不区分大小写)",
"blacklistTooltip": "💡 黑名单模式:不跟单包含上述任意关键字的市场标题(不区分大小写)",
"marketEndDateFilter": "市场截止时间限制",
"maxMarketEndDate": "最大市场截止时间",
"maxMarketEndDateTooltip": "仅跟单截止时间小于设定时间的订单。例如:24 小时表示只跟单距离结算还剩24小时以内的市场",
"maxMarketEndDatePlaceholder": "输入时间值(可选)",
"timeUnit": "单位",
"hour": "小时",
"day": "天",
"maxMarketEndDateNote": "💡 说明:不填写表示不启用此限制",
"supportSell": "跟单卖出",
"supportSellTooltip": "是否跟单 Leader 的卖出订单",
"create": "创建跟单配置",
@@ -851,6 +874,25 @@
"maxPositionCount": "最大仓位数量",
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"keywordFilter": "关键字过滤",
"keywordFilterMode": "过滤模式",
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
"disabled": "不启用",
"whitelist": "白名单",
"blacklist": "黑名单",
"keywords": "关键字",
"keywordPlaceholder": "输入关键字,按回车添加",
"keywordExists": "关键字已存在",
"whitelistTooltip": "💡 白名单模式:只跟单包含上述任意关键字的市场标题(不区分大小写)",
"blacklistTooltip": "💡 黑名单模式:不跟单包含上述任意关键字的市场标题(不区分大小写)",
"marketEndDateFilter": "市场截止时间限制",
"maxMarketEndDate": "最大市场截止时间",
"maxMarketEndDateTooltip": "仅跟单截止时间小于设定时间的订单。例如:24 小时表示只跟单距离结算还剩24小时以内的市场",
"maxMarketEndDatePlaceholder": "输入时间值(可选)",
"timeUnit": "单位",
"hour": "小时",
"day": "天",
"maxMarketEndDateNote": "💡 说明:不填写表示不启用此限制",
"supportSell": "跟单卖出",
"supportSellTooltip": "是否跟单 Leader 的卖出订单",
"save": "保存",
@@ -883,6 +925,7 @@
"priceRange": "价格区间不符",
"maxPositionValue": "超过最大仓位金额",
"maxPositionCount": "超过最大仓位数量",
"marketEndDate": "市场截止时间超出限制",
"unknown": "未知原因"
},
"noData": "暂无已过滤订单"
@@ -1053,6 +1096,15 @@
"noBuyOrders": "暂无买入订单",
"noSellOrders": "暂无卖出订单",
"noMatchedOrders": "暂无匹配关系",
"groupByMarket": "按市场分组",
"expandAll": "展开全部",
"collapseAll": "折叠全部",
"allFullyMatched": "全部成交",
"partiallyMatched": "部分成交",
"orderCount": "订单数",
"totalAmount": "总金额",
"statusBreakdown": "状态",
"markets": "个市场",
"fetchBuyOrdersFailed": "获取买入订单列表失败",
"fetchSellOrdersFailed": "获取卖出订单列表失败",
"fetchMatchedOrdersFailed": "获取匹配关系列表失败",
+55 -2
View File
@@ -30,7 +30,10 @@
"total": "共",
"items": "條",
"prev": "上一頁",
"next": "一頁"
"previous": "一頁",
"next": "下一頁",
"page": "頁",
"pageOf": "第"
},
"account": {
"title": "賬戶管理",
@@ -203,7 +206,8 @@
"walletType": "錢包類型",
"walletTypeHelp": "Web3錢包:使用 MetaMask 等瀏覽器錢包連接的 Polymarket 帳戶\nMagic:透過郵箱或社群帳號(如 Google、Twitter)登入的 Polymarket 帳戶",
"walletTypeMagic": "Magic(郵箱/社群帳號登入)",
"walletTypeSafe": "Web3錢包"
"walletTypeSafe": "Web3錢包",
"magicNotSupported": "(暫不支持)"
},
"leader": {
"title": "Leader 管理",
@@ -753,6 +757,25 @@
"maxPositionCount": "最大倉位數量",
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"keywordFilter": "關鍵字過濾",
"keywordFilterMode": "過濾模式",
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
"disabled": "不啟用",
"whitelist": "白名單",
"blacklist": "黑名單",
"keywords": "關鍵字",
"keywordPlaceholder": "輸入關鍵字,按回車添加",
"keywordExists": "關鍵字已存在",
"whitelistTooltip": "💡 白名單模式:只跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
"blacklistTooltip": "💡 黑名單模式:不跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
"marketEndDateFilter": "市場截止時間限制",
"maxMarketEndDate": "最大市場截止時間",
"maxMarketEndDateTooltip": "僅跟單截止時間小於設定時間的訂單。例如:24 小時表示只跟單距離結算還剩24小時內的市場",
"maxMarketEndDatePlaceholder": "輸入時間值(可選)",
"timeUnit": "單位",
"hour": "小時",
"day": "天",
"maxMarketEndDateNote": "💡 說明:不填寫表示不啟用此限制",
"configName": "配置名",
"configNameRequired": "請輸入配置名",
"configNamePlaceholder": "例如:跟單配置1",
@@ -839,6 +862,25 @@
"maxPositionCount": "最大倉位數量",
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"keywordFilter": "關鍵字過濾",
"keywordFilterMode": "過濾模式",
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
"disabled": "不啟用",
"whitelist": "白名單",
"blacklist": "黑名單",
"keywords": "關鍵字",
"keywordPlaceholder": "輸入關鍵字,按回車添加",
"keywordExists": "關鍵字已存在",
"whitelistTooltip": "💡 白名單模式:只跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
"blacklistTooltip": "💡 黑名單模式:不跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
"marketEndDateFilter": "市場截止時間限制",
"maxMarketEndDate": "最大市場截止時間",
"maxMarketEndDateTooltip": "僅跟單截止時間小於設定時間的訂單。例如:24 小時表示只跟單距離結算還剩24小時內的市場",
"maxMarketEndDatePlaceholder": "輸入時間值(可選)",
"timeUnit": "單位",
"hour": "小時",
"day": "天",
"maxMarketEndDateNote": "💡 說明:不填寫表示不啟用此限制",
"configName": "配置名",
"configNameRequired": "請輸入配置名",
"configNamePlaceholder": "例如:跟單配置1",
@@ -883,6 +925,7 @@
"priceRange": "價格區間不符",
"maxPositionValue": "超過最大倉位金額",
"maxPositionCount": "超過最大倉位數量",
"marketEndDate": "市場截止時間超出限制",
"unknown": "未知原因"
},
"noData": "暫無已過濾訂單"
@@ -1053,6 +1096,16 @@
"noBuyOrders": "暫無買入訂單",
"noSellOrders": "暫無賣出訂單",
"noMatchedOrders": "暫無匹配關係",
"groupByMarket": "按市場分組",
"expandAll": "展開全部",
"collapseAll": "折疊全部",
"allFullyMatched": "全部成交",
"partiallyMatched": "部分成交",
"orderCount": "訂單數",
"totalAmount": "總金額",
"totalPnl": "總盈虧",
"statusBreakdown": "狀態",
"markets": "個市場",
"fetchBuyOrdersFailed": "獲取買入訂單列表失敗",
"fetchSellOrdersFailed": "獲取賣出訂單列表失敗",
"fetchMatchedOrdersFailed": "獲取匹配關係列表失敗",
-614
View File
@@ -1,614 +0,0 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Card, Form, Button, Switch, message, Typography, Space, Radio, InputNumber, Divider, Spin, Select, Input } from 'antd'
import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import type { CopyTrading, CopyTradingUpdateRequest } from '../types'
import { useTranslation } from 'react-i18next'
const { Title } = Typography
const { Option } = Select
const CopyTradingEdit: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
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)
useEffect(() => {
if (id) {
fetchCopyTrading(parseInt(id))
}
}, [id])
const fetchCopyTrading = async (copyTradingId: number) => {
setFetching(true)
try {
const response = await apiService.copyTrading.list({})
if (response.data.code === 0 && response.data.data) {
const found = response.data.data.list.find((ct: CopyTrading) => ct.id === copyTradingId)
if (found) {
setCopyTrading(found)
setCopyMode(found.copyMode)
setOriginalEnabled(found.enabled) // 保存原始的enabled状态
// 填充表单数据
form.setFieldsValue({
accountId: found.accountId,
leaderId: found.leaderId,
copyMode: found.copyMode,
copyRatio: found.copyRatio ? parseFloat(found.copyRatio) * 100 : 100, // 转换为百分比显示
fixedAmount: found.fixedAmount ? parseFloat(found.fixedAmount) : undefined,
maxOrderSize: found.maxOrderSize ? parseFloat(found.maxOrderSize) : undefined,
minOrderSize: found.minOrderSize ? parseFloat(found.minOrderSize) : undefined,
maxDailyLoss: found.maxDailyLoss ? parseFloat(found.maxDailyLoss) : undefined,
maxDailyOrders: found.maxDailyOrders,
priceTolerance: found.priceTolerance ? parseFloat(found.priceTolerance) : undefined,
delaySeconds: found.delaySeconds,
pollIntervalSeconds: found.pollIntervalSeconds,
useWebSocket: found.useWebSocket,
websocketReconnectInterval: found.websocketReconnectInterval,
websocketMaxRetries: found.websocketMaxRetries,
supportSell: found.supportSell,
minOrderDepth: found.minOrderDepth ? parseFloat(found.minOrderDepth) : undefined,
maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined,
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
maxPositionValue: found.maxPositionValue ? parseFloat(found.maxPositionValue) : undefined,
maxPositionCount: found.maxPositionCount,
configName: found.configName || '',
pushFailedOrders: found.pushFailedOrders ?? false
})
} else {
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
navigate('/copy-trading')
}
} else {
message.error(response.data.msg || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败')
navigate('/copy-trading')
}
} catch (error: any) {
message.error(error.message || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败')
navigate('/copy-trading')
} finally {
setFetching(false)
}
}
const handleCopyModeChange = (mode: 'RATIO' | 'FIXED') => {
setCopyMode(mode)
}
const handleSubmit = async (values: any) => {
// 前端校验
if (values.copyMode === 'FIXED') {
if (!values.fixedAmount || Number(values.fixedAmount) < 1) {
message.error('固定金额必须 >= 1')
return
}
}
if (values.copyMode === 'RATIO' && values.minOrderSize !== undefined && values.minOrderSize !== null && Number(values.minOrderSize) < 1) {
message.error('最小金额必须 >= 1')
return
}
if (!id) {
message.error('配置ID不存在')
return
}
setLoading(true)
try {
const request: CopyTradingUpdateRequest = {
copyTradingId: parseInt(id),
enabled: originalEnabled, // 保持原有的enabled状态,不修改
copyMode: values.copyMode,
copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined,
fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount?.toString() : undefined,
maxOrderSize: values.maxOrderSize?.toString(),
minOrderSize: values.minOrderSize?.toString(),
maxDailyLoss: values.maxDailyLoss?.toString(),
maxDailyOrders: values.maxDailyOrders,
priceTolerance: values.priceTolerance?.toString(),
delaySeconds: values.delaySeconds,
pollIntervalSeconds: values.pollIntervalSeconds,
useWebSocket: values.useWebSocket,
websocketReconnectInterval: values.websocketReconnectInterval,
websocketMaxRetries: values.websocketMaxRetries,
supportSell: values.supportSell,
minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(),
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString(),
maxPositionValue: values.maxPositionValue?.toString(),
maxPositionCount: values.maxPositionCount,
configName: values.configName?.trim() || undefined,
pushFailedOrders: values.pushFailedOrders
}
const response = await apiService.copyTrading.update(request)
if (response.data.code === 0) {
message.success(t('copyTradingEdit.saveSuccess') || '更新跟单配置成功')
navigate('/copy-trading')
} else {
message.error(response.data.msg || t('copyTradingEdit.saveFailed') || '更新跟单配置失败')
}
} catch (error: any) {
message.error(error.message || t('copyTradingEdit.saveFailed') || '更新跟单配置失败')
} finally {
setLoading(false)
}
}
if (fetching) {
return (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
)
}
if (!copyTrading) {
return null
}
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/copy-trading')}
>
{t('common.back') || '返回'}
</Button>
</div>
<Card>
<Title level={4}>{t('copyTradingEdit.title') || '编辑跟单配置'}</Title>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
{/* 基础信息(只读) */}
<Form.Item
label={t('copyTradingEdit.configName') || '配置名'}
name="configName"
rules={[
{ required: true, message: t('copyTradingEdit.configNameRequired') || '请输入配置名' },
{ whitespace: true, message: t('copyTradingEdit.configNameRequired') || '配置名不能为空' }
]}
tooltip={t('copyTradingEdit.configNameTooltip') || '为跟单配置设置一个名称,便于识别和管理'}
>
<Input
placeholder={t('copyTradingEdit.configNamePlaceholder') || '例如:跟单配置1'}
maxLength={255}
/>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.selectWallet') || t('copyTradingEdit.selectWallet') || '钱包'}
name="accountId"
>
<Select disabled>
<Option value={copyTrading.accountId}>
{copyTrading.accountName || `账户 ${copyTrading.accountId}`} ({copyTrading.walletAddress.slice(0, 6)}...{copyTrading.walletAddress.slice(-4)})
</Option>
</Select>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.selectLeader') || t('copyTradingEdit.selectLeader') || 'Leader'}
name="leaderId"
>
<Select disabled>
<Option value={copyTrading.leaderId}>
{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`} ({copyTrading.leaderAddress.slice(0, 6)}...{copyTrading.leaderAddress.slice(-4)})
</Option>
</Select>
</Form.Item>
<Divider>{t('copyTradingEdit.basicConfig') || '基础配置'}</Divider>
{/* 跟单金额模式 */}
<Form.Item
label={t('copyTradingEdit.copyMode') || '跟单金额模式'}
name="copyMode"
tooltip={t('copyTradingEdit.copyModeTooltip') || '选择跟单金额的计算方式'}
rules={[{ required: true }]}
>
<Radio.Group onChange={(e) => handleCopyModeChange(e.target.value)}>
<Radio value="RATIO">{t('copyTradingEdit.ratioMode') || '比例模式'}</Radio>
<Radio value="FIXED">{t('copyTradingEdit.fixedAmountMode') || '固定金额模式'}</Radio>
</Radio.Group>
</Form.Item>
{copyMode === 'RATIO' && (
<Form.Item
label={t('copyTradingEdit.copyRatio') || '跟单比例'}
name="copyRatio"
tooltip={t('copyTradingEdit.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比'}
>
<InputNumber
min={0.01}
max={10000}
step={0.01}
precision={2}
style={{ width: '100%' }}
addonAfter="%"
placeholder={t('copyTradingEdit.copyRatioPlaceholder') || '例如:100 表示 100%1:1 跟单)'}
parser={(value) => {
console.log('[CopyTradingEdit copyRatio parser] 输入值:', value, '类型:', typeof value)
// 移除 % 符号和其他非数字字符(保留小数点和负号)
const cleaned = (value || '').toString().replace(/%/g, '').trim()
console.log('[CopyTradingEdit copyRatio parser] 清理后:', cleaned)
const parsed = parseFloat(cleaned) || 0
console.log('[CopyTradingEdit copyRatio parser] 解析后:', parsed)
if (parsed > 10000) {
console.log('[CopyTradingEdit copyRatio parser] 超过最大值,返回 10000')
return 10000
}
if (parsed < 0.01) {
console.log('[CopyTradingEdit copyRatio parser] 小于最小值,返回 0.01')
return 0.01
}
console.log('[CopyTradingEdit copyRatio parser] 返回:', parsed)
return parsed
}}
formatter={(value) => {
console.log('[CopyTradingEdit copyRatio formatter] 输入值:', value, '类型:', typeof value)
if (!value && value !== 0) {
console.log('[CopyTradingEdit copyRatio formatter] 空值,返回空字符串')
return ''
}
const num = parseFloat(value.toString())
console.log('[CopyTradingEdit copyRatio formatter] 解析后:', num)
if (isNaN(num)) {
console.log('[CopyTradingEdit copyRatio formatter] NaN,返回空字符串')
return ''
}
if (num > 10000) {
console.log('[CopyTradingEdit copyRatio formatter] 超过最大值,返回 10000')
return '10000'
}
const result = num.toString().replace(/\.0+$/, '')
console.log('[CopyTradingEdit copyRatio formatter] 格式化后返回:', result)
return result
}}
/>
</Form.Item>
)}
{copyMode === 'FIXED' && (
<Form.Item
label={t('copyTradingEdit.fixedAmount') || '固定跟单金额 (USDC)'}
name="fixedAmount"
rules={[
{ required: true, message: t('copyTradingEdit.fixedAmountRequired') || '请输入固定跟单金额' },
{
validator: (_, value) => {
if (value !== undefined && value !== null && value !== '') {
const amount = Number(value)
if (isNaN(amount)) {
return Promise.reject(new Error(t('copyTradingEdit.invalidNumber') || '请输入有效的数字'))
}
if (amount < 1) {
return Promise.reject(new Error(t('copyTradingEdit.fixedAmountMin') || '固定金额必须 >= 1'))
}
}
return Promise.resolve()
}
}
]}
>
<InputNumber
min={1}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
)}
{copyMode === 'RATIO' && (
<>
<Form.Item
label={t('copyTradingEdit.maxOrderSize') || '单笔订单最大金额 (USDC)'}
name="maxOrderSize"
tooltip={t('copyTradingEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限'}
>
<InputNumber
min={0.0001}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxOrderSizePlaceholder') || '仅在比例模式下生效(可选)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.minOrderSize') || '单笔订单最小金额 (USDC)'}
name="minOrderSize"
tooltip={t('copyTradingEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1'}
rules={[
{
validator: (_, value) => {
if (value === undefined || value === null || value === '') {
return Promise.resolve()
}
if (typeof value === 'number' && value < 1) {
return Promise.reject(new Error(t('copyTradingEdit.minOrderSizeMin') || '最小金额必须 >= 1'))
}
return Promise.resolve()
}
}
]}
>
<InputNumber
min={1}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
</>
)}
<Form.Item
label={t('copyTradingEdit.maxDailyLoss') || '每日最大亏损限制 (USDC)'}
name="maxDailyLoss"
tooltip={t('copyTradingEdit.maxDailyLossTooltip') || '限制每日最大亏损金额,用于风险控制'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxDailyOrders') || '每日最大跟单订单数'}
name="maxDailyOrders"
tooltip={t('copyTradingEdit.maxDailyOrdersTooltip') || '限制每日最多跟单的订单数量'}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxDailyOrdersPlaceholder') || '默认 100(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.priceTolerance') || '价格容忍度 (%)'}
name="priceTolerance"
tooltip={t('copyTradingEdit.priceToleranceTooltip') || '允许跟单价格在 Leader 价格基础上的调整范围'}
>
<InputNumber
min={0}
max={100}
step={0.1}
precision={2}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.priceTolerancePlaceholder') || '默认 5%(可选)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.delaySeconds') || '跟单延迟 (秒)'}
name="delaySeconds"
tooltip={t('copyTradingEdit.delaySecondsTooltip') || '跟单延迟时间,0 表示立即跟单'}
>
<InputNumber
min={0}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.delaySecondsPlaceholder') || '默认 0(立即跟单)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth"
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.minOrderDepthPlaceholder') || '例如:100(可选,不填写表示不启用)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread"
tooltip={t('copyTradingEdit.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxSpreadPlaceholder') || '例如:0.05(5美分,可选,不填写表示不启用)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<Divider>{t('copyTradingEdit.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item
label={t('copyTradingEdit.priceRange') || '价格区间'}
name="priceRange"
tooltip={t('copyTradingEdit.priceRangeTooltip') || '配置价格区间,仅在指定价格区间内的订单才会下单。例如:0.11-0.89 表示区间在0.11和0.89之间;-0.89 表示0.89以下都可以;0.11- 表示0.11以上都可以'}
>
<Input.Group compact style={{ display: 'flex' }}>
<Form.Item name="minPrice" noStyle>
<InputNumber
min={0.01}
max={0.99}
step={0.0001}
precision={4}
style={{ width: '50%' }}
placeholder={t('copyTradingEdit.minPricePlaceholder') || '最低价(可选)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<span style={{ display: 'inline-block', width: '20px', textAlign: 'center', lineHeight: '32px' }}>-</span>
<Form.Item name="maxPrice" noStyle>
<InputNumber
min={0.01}
max={0.99}
step={0.0001}
precision={4}
style={{ width: '50%' }}
placeholder={t('copyTradingEdit.maxPricePlaceholder') || '最高价(可选)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
</Input.Group>
</Form.Item>
<Divider>{t('copyTradingEdit.positionLimitFilter') || '最大仓位限制'}</Divider>
<Form.Item
label={t('copyTradingEdit.maxPositionValue') || '最大仓位金额 (USDC)'}
name="maxPositionValue"
tooltip={t('copyTradingEdit.maxPositionValueTooltip') || '限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxPositionValuePlaceholder') || '例如:100(可选,不填写表示不启用)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</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.advancedSettings') || '高级设置'}</Divider>
{/* 跟单卖出 */}
<Form.Item
label={t('copyTradingEdit.supportSell') || '跟单卖出'}
name="supportSell"
tooltip={t('copyTradingEdit.supportSellTooltip') || '是否跟单 Leader 的卖出订单'}
valuePropName="checked"
>
<Switch />
</Form.Item>
{/* 推送失败订单 */}
<Form.Item
label={t('copyTradingEdit.pushFailedOrders') || '推送失败订单'}
name="pushFailedOrders"
tooltip={t('copyTradingEdit.pushFailedOrdersTooltip') || '开启后,失败的订单会推送到 Telegram'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={loading}
>
{t('copyTradingEdit.save') || '保存'}
</Button>
<Button onClick={() => navigate('/copy-trading')}>
{t('common.cancel') || '取消'}
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default CopyTradingEdit
+10 -3
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Divider, Spin } from 'antd'
import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
@@ -13,12 +12,12 @@ import CopyTradingOrdersModal from './CopyTradingOrders/index'
import StatisticsModal from './CopyTradingOrders/StatisticsModal'
import FilteredOrdersModal from './CopyTradingOrders/FilteredOrdersModal'
import EditModal from './CopyTradingOrders/EditModal'
import AddModal from './CopyTradingOrders/AddModal'
const { Option } = Select
const CopyTradingList: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
const [copyTradings, setCopyTradings] = useState<CopyTrading[]>([])
@@ -42,6 +41,7 @@ const CopyTradingList: React.FC = () => {
const [filteredOrdersModalCopyTradingId, setFilteredOrdersModalCopyTradingId] = useState<string>('')
const [editModalOpen, setEditModalOpen] = useState(false)
const [editModalCopyTradingId, setEditModalCopyTradingId] = useState<string>('')
const [addModalOpen, setAddModalOpen] = useState(false)
useEffect(() => {
fetchAccounts()
@@ -369,7 +369,7 @@ const CopyTradingList: React.FC = () => {
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/copy-trading/add')}
onClick={() => setAddModalOpen(true)}
>
{t('copyTradingList.addCopyTrading') || '新增跟单'}
</Button>
@@ -668,6 +668,13 @@ const CopyTradingList: React.FC = () => {
fetchCopyTradings()
}}
/>
<AddModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSuccess={() => {
fetchCopyTradings()
}}
/>
</div>
)
}
@@ -1,22 +1,29 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Button, Switch, message, Typography, Space, Radio, InputNumber, Modal, Table, Select, Divider, Input } from 'antd'
import { ArrowLeftOutlined, SaveOutlined, FileTextOutlined, PlusOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import type { Leader, CopyTradingTemplate, CopyTradingCreateRequest } from '../types'
import { formatUSDC } from '../utils'
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 { SaveOutlined, FileTextOutlined, PlusOutlined } from '@ant-design/icons'
import { apiService } from '../../services/api'
import { useAccountStore } from '../../store/accountStore'
import type { Leader, CopyTradingTemplate, CopyTradingCreateRequest } from '../../types'
import { formatUSDC } from '../../utils'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import AccountImportForm from '../components/AccountImportForm'
import LeaderAddForm from '../components/LeaderAddForm'
import AccountImportForm from '../../components/AccountImportForm'
import LeaderAddForm from '../../components/LeaderAddForm'
const { Title } = Typography
const { Option } = Select
const CopyTradingAdd: React.FC = () => {
interface AddModalProps {
open: boolean
onClose: () => void
onSuccess?: () => void
}
const AddModal: React.FC<AddModalProps> = ({
open,
onClose,
onSuccess
}) => {
const { t } = useTranslation()
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
const [form] = Form.useForm()
@@ -25,6 +32,10 @@ const CopyTradingAdd: React.FC = () => {
const [templates, setTemplates] = useState<CopyTradingTemplate[]>([])
const [templateModalVisible, setTemplateModalVisible] = useState(false)
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
const [keywords, setKeywords] = useState<string[]>([])
const keywordInputRef = useRef<InputRef>(null)
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR')
// 导入账户modal相关状态
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
@@ -52,14 +63,24 @@ const CopyTradingAdd: React.FC = () => {
}
useEffect(() => {
fetchAccounts()
fetchLeaders()
fetchTemplates()
// 生成默认配置名
const defaultConfigName = generateDefaultConfigName()
form.setFieldsValue({ configName: defaultConfigName })
}, [])
if (open) {
fetchAccounts()
fetchLeaders()
fetchTemplates()
// 生成默认配置名
const defaultConfigName = generateDefaultConfigName()
form.setFieldsValue({ configName: defaultConfigName })
// 重置关键字列表
setKeywords([])
} else {
// 关闭时重置表单
form.resetFields()
setKeywords([])
setCopyMode('RATIO')
}
}, [open])
const fetchLeaders = async () => {
try {
@@ -140,7 +161,46 @@ const CopyTradingAdd: React.FC = () => {
leaderAddForm.resetFields()
}
const handleSubmit = async (values: any) => {
// 添加关键字
const handleAddKeyword = (e?: React.KeyboardEvent<HTMLInputElement>) => {
let inputValue = ''
if (e) {
// 从键盘事件获取输入值
const target = e.target as HTMLInputElement
inputValue = target.value.trim()
} else if (keywordInputRef.current) {
// 从输入框 ref 获取值
inputValue = keywordInputRef.current.input?.value?.trim() || ''
}
if (!inputValue) {
return
}
// 检查是否已存在
if (keywords.includes(inputValue)) {
message.warning(t('copyTradingAdd.keywordExists') || '关键字已存在')
return
}
// 添加关键字
const newKeywords = [...keywords, inputValue]
setKeywords(newKeywords)
// 清空输入框
if (keywordInputRef.current) {
keywordInputRef.current.input!.value = ''
}
}
// 删除关键字
const handleRemoveKeyword = (index: number) => {
const newKeywords = keywords.filter((_, i) => i !== index)
setKeywords(newKeywords)
}
const handleSubmit = async (values: any) => {
// 前端校验
if (values.copyMode === 'FIXED') {
if (!values.fixedAmount || Number(values.fixedAmount) < 1) {
@@ -154,6 +214,15 @@ const CopyTradingAdd: React.FC = () => {
return
}
// 计算市场截止时间(毫秒)
let maxMarketEndDate: number | undefined
if (maxMarketEndDateValue !== undefined && maxMarketEndDateValue > 0) {
const multiplier = maxMarketEndDateUnit === 'HOUR'
? 60 * 60 * 1000 // 小时转毫秒
: 24 * 60 * 60 * 1000 // 天转毫秒
maxMarketEndDate = maxMarketEndDateValue * multiplier
}
setLoading(true)
try {
const request: CopyTradingCreateRequest = {
@@ -180,15 +249,23 @@ const CopyTradingAdd: React.FC = () => {
maxPrice: values.maxPrice?.toString(),
maxPositionValue: values.maxPositionValue?.toString(),
maxPositionCount: values.maxPositionCount,
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
? keywords
: undefined,
configName: values.configName?.trim(),
pushFailedOrders: values.pushFailedOrders ?? false
pushFailedOrders: values.pushFailedOrders ?? false,
maxMarketEndDate
}
const response = await apiService.copyTrading.create(request)
if (response.data.code === 0) {
message.success(t('copyTradingAdd.createSuccess') || '创建跟单配置成功')
navigate('/copy-trading')
onClose()
if (onSuccess) {
onSuccess()
}
} else {
message.error(response.data.msg || t('copyTradingAdd.createFailed') || '创建跟单配置失败')
}
@@ -200,19 +277,17 @@ const CopyTradingAdd: React.FC = () => {
}
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/copy-trading')}
>
{t('common.back') || '返回'}
</Button>
</div>
<Card>
<Title level={4}>{t('copyTradingAdd.title') || '新增跟单配置'}</Title>
<>
<Modal
title={t('copyTradingAdd.title') || '新增跟单配置'}
open={open}
onCancel={onClose}
footer={null}
width="90%"
style={{ top: 20 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
destroyOnClose
>
<Form
form={form}
layout="vertical"
@@ -231,7 +306,8 @@ const CopyTradingAdd: React.FC = () => {
websocketReconnectInterval: 5000,
websocketMaxRetries: 10,
supportSell: true,
pushFailedOrders: false
pushFailedOrders: false,
keywordFilterMode: 'DISABLED'
}}
>
{/* 基础信息 */}
@@ -352,42 +428,18 @@ const CopyTradingAdd: React.FC = () => {
addonAfter="%"
placeholder={t('copyTradingAdd.copyRatioPlaceholder') || '例如:100 表示 100%1:1 跟单),默认 100%'}
parser={(value) => {
console.log('[CopyTradingAdd copyRatio parser] 输入值:', value, '类型:', typeof value)
// 移除 % 符号和其他非数字字符(保留小数点和负号)
const cleaned = (value || '').toString().replace(/%/g, '').trim()
console.log('[CopyTradingAdd copyRatio parser] 清理后:', cleaned)
const parsed = parseFloat(cleaned) || 0
console.log('[CopyTradingAdd copyRatio parser] 解析后:', parsed)
if (parsed > 10000) {
console.log('[CopyTradingAdd copyRatio parser] 超过最大值,返回 10000')
return 10000
}
if (parsed < 0.01) {
console.log('[CopyTradingAdd copyRatio parser] 小于最小值,返回 0.01')
return 0.01
}
console.log('[CopyTradingAdd copyRatio parser] 返回:', parsed)
if (parsed > 10000) return 10000
if (parsed < 0.01) return 0.01
return parsed
}}
formatter={(value) => {
console.log('[CopyTradingAdd copyRatio formatter] 输入值:', value, '类型:', typeof value)
if (!value && value !== 0) {
console.log('[CopyTradingAdd copyRatio formatter] 空值,返回空字符串')
return ''
}
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
console.log('[CopyTradingAdd copyRatio formatter] 解析后:', num)
if (isNaN(num)) {
console.log('[CopyTradingAdd copyRatio formatter] NaN,返回空字符串')
return ''
}
if (num > 10000) {
console.log('[CopyTradingAdd copyRatio formatter] 超过最大值,返回 10000')
return '10000'
}
const result = num.toString().replace(/\.0+$/, '')
console.log('[CopyTradingAdd copyRatio formatter] 格式化后返回:', result)
return result
if (isNaN(num)) return ''
if (num > 10000) return '10000'
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
@@ -674,6 +726,120 @@ const CopyTradingAdd: React.FC = () => {
/>
</Form.Item>
<Divider>{t('copyTradingAdd.keywordFilter') || '关键字过滤'}</Divider>
<Form.Item
label={t('copyTradingAdd.keywordFilterMode') || '过滤模式'}
name="keywordFilterMode"
tooltip={t('copyTradingAdd.keywordFilterModeTooltip') || '选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤'}
>
<Radio.Group>
<Radio value="DISABLED">{t('copyTradingAdd.disabled') || '不启用'}</Radio>
<Radio value="WHITELIST">{t('copyTradingAdd.whitelist') || '白名单'}</Radio>
<Radio value="BLACKLIST">{t('copyTradingAdd.blacklist') || '黑名单'}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) =>
prevValues.keywordFilterMode !== currentValues.keywordFilterMode
}>
{({ getFieldValue }) => {
const filterMode = getFieldValue('keywordFilterMode')
if (filterMode !== 'WHITELIST' && filterMode !== 'BLACKLIST') {
return null
}
return (
<>
<Form.Item label={t('copyTradingAdd.keywords') || '关键字'}>
<Space.Compact style={{ width: '100%' }}>
<Input
ref={keywordInputRef}
placeholder={t('copyTradingAdd.keywordPlaceholder') || '输入关键字,按回车添加'}
onPressEnter={(e) => handleAddKeyword(e)}
/>
<Button
type="primary"
onClick={() => handleAddKeyword()}
>
{t('common.add') || '添加'}
</Button>
</Space.Compact>
{keywords.length > 0 && (
<div style={{ marginTop: 8 }}>
<Space wrap>
{keywords.map((keyword, index) => (
<Tag
key={index}
closable
onClose={() => handleRemoveKeyword(index)}
color={filterMode === 'WHITELIST' ? 'green' : 'red'}
>
{keyword}
</Tag>
))}
</Space>
</div>
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
{filterMode === 'WHITELIST'
? (t('copyTradingAdd.whitelistTooltip') || '💡 白名单模式:只跟单包含上述任意关键字的市场标题')
: (t('copyTradingAdd.blacklistTooltip') || '💡 黑名单模式:不跟单包含上述任意关键字的市场标题')
}
</div>
</Form.Item>
</>
)
}}
</Form.Item>
{/* 市场截止时间限制 */}
<Divider>{t('copyTradingAdd.marketEndDateFilter') || '市场截止时间限制'}</Divider>
<Form.Item
label={t('copyTradingAdd.maxMarketEndDate') || '最大市场截止时间'}
tooltip={t('copyTradingAdd.maxMarketEndDateTooltip') || '仅跟单截止时间小于设定时间的订单。例如:24 小时表示只跟单距离结算还剩24小时以内的市场'}
>
<Input.Group compact style={{ display: 'flex' }}>
<InputNumber
min={1}
max={9999}
step={1}
precision={0}
value={maxMarketEndDateValue}
onChange={(value) => setMaxMarketEndDateValue(value !== null && value !== undefined ? Math.floor(value) : undefined)}
style={{ width: '60%' }}
placeholder={t('copyTradingAdd.maxMarketEndDatePlaceholder') || '输入时间值(可选)'}
parser={(value) => {
if (!value) return 0
const num = parseInt(value.replace(/\D/g, ''), 10)
return isNaN(num) ? 0 : num
}}
formatter={(value) => {
if (!value && value !== 0) return ''
return Math.floor(value).toString()
}}
/>
<Select
value={maxMarketEndDateUnit}
onChange={(value) => setMaxMarketEndDateUnit(value)}
style={{ width: '40%' }}
placeholder={t('copyTradingAdd.timeUnit') || '单位'}
>
<Option value="HOUR">{t('copyTradingAdd.hour') || '小时'}</Option>
<Option value="DAY">{t('copyTradingAdd.day') || '天'}</Option>
</Select>
</Input.Group>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<div style={{ fontSize: 12, color: '#999' }}>
{t('copyTradingAdd.maxMarketEndDateNote') || '💡 说明:不填写表示不启用此限制'}
</div>
</Form.Item>
<Divider>{t('copyTradingAdd.advancedSettings') || '高级设置'}</Divider>
{/* 跟单卖出 */}
@@ -706,13 +872,13 @@ const CopyTradingAdd: React.FC = () => {
>
{t('copyTradingAdd.create') || '创建跟单配置'}
</Button>
<Button onClick={() => navigate('/copy-trading')}>
<Button onClick={onClose}>
{t('common.cancel') || '取消'}
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</Modal>
{/* 模板选择 Modal */}
<Modal
@@ -812,8 +978,9 @@ const CopyTradingAdd: React.FC = () => {
showCancelButton={true}
/>
</Modal>
</div>
</>
)
}
export default CopyTradingAdd
export default AddModal
@@ -1,14 +1,14 @@
import { useEffect, useState } from 'react'
import { Table, Tag, Select, Input, Button, Card, Divider, Spin } from 'antd'
import { Table, Tag, Select, Input, Button, Card, Divider, Spin, message, Switch, Space } from 'antd'
import { apiService } from '../../services/api'
import { formatUSDC } from '../../utils'
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard, getPolymarketUrl } from '../../utils'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
import type { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse, MarketGroupedOrdersRequest, MarketGroupedOrdersResponse, MarketOrderGroup } from '../../types'
const { Option } = Select
import { ReloadOutlined } from '@ant-design/icons'
import { ReloadOutlined, CopyOutlined, DownOutlined, UpOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons'
interface BuyOrdersTabProps {
copyTradingId: string
@@ -28,31 +28,53 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
side?: string
status?: string
}>({})
const [groupByMarket, setGroupByMarket] = useState(false) // 是否按市场分组
const [expandedMarkets, setExpandedMarkets] = useState<Set<string>>(new Set()) // 展开的市场ID集合
const [groupedOrders, setGroupedOrders] = useState<MarketOrderGroup[]>([]) // 分组数据(从后端获取)
const [groupedTotal, setGroupedTotal] = useState(0) // 分组总数(市场数量)
useEffect(() => {
if (copyTradingId && active) {
fetchOrders()
}
}, [copyTradingId, active, page, limit, filters])
}, [copyTradingId, active, page, limit, filters, groupByMarket])
const fetchOrders = async () => {
if (!copyTradingId) return
setLoading(true)
try {
const request: OrderTrackingRequest = {
copyTradingId: parseInt(copyTradingId),
type: 'buy',
page,
limit,
...filters
}
if (groupByMarket) {
// 调用分组接口
const request: MarketGroupedOrdersRequest = {
copyTradingId: parseInt(copyTradingId),
type: 'buy',
page,
limit
}
const response = await apiService.orderTracking.list(request)
if (response.data.code === 0 && response.data.data) {
const data = response.data.data as OrderTrackingListResponse
setOrders((data.list || []) as BuyOrderInfo[])
setTotal(data.total || 0)
const response = await apiService.orderTracking.listGroupedByMarket(request)
if (response.data.code === 0 && response.data.data) {
const data = response.data.data as MarketGroupedOrdersResponse
setGroupedOrders(data.list || [])
setGroupedTotal(data.total || 0)
}
} else {
// 调用普通列表接口
const request: OrderTrackingRequest = {
copyTradingId: parseInt(copyTradingId),
type: 'buy',
page,
limit,
...filters
}
const response = await apiService.orderTracking.list(request)
if (response.data.code === 0 && response.data.data) {
const data = response.data.data as OrderTrackingListResponse
setOrders((data.list || []) as BuyOrderInfo[])
setTotal(data.total || 0)
}
}
} catch (error: any) {
console.error('获取买入订单列表失败:', error)
@@ -71,20 +93,65 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
return <Tag color={config.color}>{config.text}</Tag>
}
const handleCopyOrderId = async (orderId: string) => {
const success = await copyToClipboard(orderId)
if (success) {
message.success(t('common.copySuccess') || '已复制到剪贴板')
} else {
message.error(t('common.copyFailed') || '复制失败')
}
}
// 切换市场展开/折叠
const toggleMarket = (marketId: string) => {
const newExpanded = new Set(expandedMarkets)
if (newExpanded.has(marketId)) {
newExpanded.delete(marketId)
} else {
newExpanded.add(marketId)
}
setExpandedMarkets(newExpanded)
}
// 展开/折叠全部
const toggleAllMarkets = () => {
if (expandedMarkets.size === groupedOrders.length) {
setExpandedMarkets(new Set())
} else {
setExpandedMarkets(new Set(groupedOrders.map(g => g.marketId)))
}
}
const columns = [
{
title: t('copyTradingOrders.orderId') || '订单ID',
dataIndex: 'orderId',
key: 'orderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
width: isMobile ? 120 : 180,
render: (text: string) => {
const isAuto = isAutoGeneratedOrderId(text)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
{!isAuto && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(text)}
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
title={t('common.copy') || '复制'}
/>
)}
</div>
)
}
},
{
title: t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID',
@@ -104,15 +171,42 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
title: t('copyTradingOrders.market') || '市场',
dataIndex: 'marketId',
key: 'marketId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
width: isMobile ? 120 : 200,
render: (text: string, record: BuyOrderInfo) => {
const marketUrl = getPolymarketUrl(record.marketSlug, record.eventSlug, record.marketCategory, record.marketId)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{record.marketTitle ? (
marketUrl ? (
<a
href={marketUrl}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: isMobile ? 11 : 12,
fontWeight: 500,
color: '#1890ff',
textDecoration: 'none',
cursor: 'pointer'
}}
>
{record.marketTitle}
</a>
) : (
<span style={{ fontSize: isMobile ? 11 : 12, fontWeight: 500 }}>
{record.marketTitle}
</span>
)
) : null}
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 10 : 11, color: '#999' }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
</div>
)
}
},
{
title: t('copyTradingOrders.side') || '方向',
@@ -196,44 +290,189 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
}
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/>
// 分组默认全部折叠(不自动展开)
<Select
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.side}
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
>
<Option value="0">YES</Option>
<Option value="1">NO</Option>
</Select>
// 渲染分组视图
const renderGroupedView = () => {
if (groupedOrders.length === 0) {
return (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
{t('copyTradingOrders.noBuyOrders') || '暂无买入订单'}
</div>
)
}
<Select
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.status}
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
>
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallySold') || '部分成交'}</Option>
<Option value="fully_matched">{t('copyTradingOrders.statusFullySold') || '全部成交'}</Option>
</Select>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{groupedOrders.map((group) => {
const isExpanded = expandedMarkets.has(group.marketId)
const marketDisplayName = group.marketTitle || group.marketId.slice(0, 8) + '...' + group.marketId.slice(-6)
const marketUrl = getPolymarketUrl(group.marketSlug, group.eventSlug, group.marketCategory, group.marketId)
const orders = group.orders as BuyOrderInfo[]
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.refresh') || '刷新'}</Button>
return (
<Card
key={group.marketId}
style={{
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
}}
bodyStyle={{ padding: '16px' }}
>
{/* 分组头部 */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
padding: '8px 0',
marginBottom: isExpanded ? '12px' : 0
}}
onClick={() => toggleMarket(group.marketId)}
>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
{marketUrl ? (
<a
href={marketUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={{
fontSize: isMobile ? '14px' : '16px',
fontWeight: 'bold',
color: '#1890ff',
textDecoration: 'none',
cursor: 'pointer'
}}
>
{marketDisplayName}
</a>
) : (
<span style={{ fontSize: isMobile ? '14px' : '16px', fontWeight: 'bold' }}>
{marketDisplayName}
</span>
)}
{group.stats.fullyMatched ? (
<Tag color="success">{t('copyTradingOrders.allFullyMatched') || '全部成交'}</Tag>
) : (
<Tag color="warning">
{t('copyTradingOrders.partiallyMatched') || '部分成交'} ({group.stats.fullyMatchedCount}/{group.stats.count})
</Tag>
)}
</div>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}>
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span>
<span>
{t('copyTradingOrders.statusBreakdown') || '状态'}:
{group.stats.fullyMatchedCount > 0 && ` ${t('copyTradingOrders.statusFullySold') || '全部成交'} ${group.stats.fullyMatchedCount}`}
{group.stats.partiallyMatchedCount > 0 && ` ${t('copyTradingOrders.statusPartiallySold') || '部分成交'} ${group.stats.partiallyMatchedCount}`}
{group.stats.filledCount > 0 && ` ${t('copyTradingOrders.statusFilled') || '未成交'} ${group.stats.filledCount}`}
</span>
</div>
</div>
<Button
type="text"
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
onClick={(e) => {
e.stopPropagation()
toggleMarket(group.marketId)
}}
/>
</div>
{/* 订单列表 */}
{isExpanded && (
<>
<Divider style={{ margin: '12px 0' }} />
{isMobile ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{orders.map((order) => {
const date = new Date(order.createdAt)
const formattedDate = date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
return (
<Card
key={order.orderId}
size="small"
style={{
borderRadius: '8px',
border: '1px solid #f0f0f0',
backgroundColor: '#fafafa'
}}
bodyStyle={{ padding: '12px' }}
>
<div style={{ marginBottom: '8px' }}>
<div style={{
fontSize: '13px',
fontWeight: 'bold',
marginBottom: '6px',
fontFamily: 'monospace',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span>{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}</span>
{!isAutoGeneratedOrderId(order.orderId) && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(order.orderId)}
style={{ padding: 0, height: 'auto', fontSize: '11px' }}
title={t('common.copy') || '复制'}
/>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag style={{ fontSize: '11px' }}>{displaySide}</Tag>
{getStatusTag(order.status)}
</div>
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
<div>{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}</div>
<div>{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC</div>
<div>{t('copyTradingOrders.matched') || '已匹配'}: {formatUSDC(order.matchedQuantity)} | {t('copyTradingOrders.remaining') || '剩余'}: {formatUSDC(order.remainingQuantity)}</div>
<div style={{ color: '#999', marginTop: '4px' }}>{formattedDate}</div>
</div>
</Card>
)
})}
</div>
) : (
<Table
columns={columns}
dataSource={orders}
rowKey="orderId"
pagination={false}
size="small"
/>
)}
</>
)}
</Card>
)
})}
</div>
)
}
{isMobile ? (
// 渲染列表视图
const renderListView = () => {
if (isMobile) {
return (
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
@@ -272,9 +511,22 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
fontSize: '14px',
fontWeight: 'bold',
marginBottom: '8px',
fontFamily: 'monospace'
fontFamily: 'monospace',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}
<span>{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}</span>
{!isAutoGeneratedOrderId(order.orderId) && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(order.orderId)}
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
title={t('common.copy') || '复制'}
/>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag>{displaySide}</Tag>
@@ -309,7 +561,34 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
{order.marketTitle ? (
(() => {
const marketUrl = getPolymarketUrl(order.marketSlug, order.eventSlug, order.marketCategory, order.marketId)
return marketUrl ? (
<a
href={marketUrl}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: '13px',
fontWeight: '500',
marginBottom: '4px',
color: '#1890ff',
textDecoration: 'none',
cursor: 'pointer',
display: 'block'
}}
>
{order.marketTitle}
</a>
) : (
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
{order.marketTitle}
</div>
)
})()
) : null}
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
</div>
@@ -326,7 +605,9 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
</div>
)}
</div>
) : (
)
} else {
return (
<Table
columns={columns}
dataSource={orders}
@@ -344,6 +625,115 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
}
}}
/>
)
}
}
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<Input
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/>
<Select
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.side}
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
>
<Option value="0">YES</Option>
<Option value="1">NO</Option>
</Select>
<Select
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.status}
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
>
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallySold') || '部分成交'}</Option>
<Option value="fully_matched">{t('copyTradingOrders.statusFullySold') || '全部成交'}</Option>
</Select>
<Space>
<span style={{ fontSize: isMobile ? '12px' : '14px' }}>
{t('copyTradingOrders.groupByMarket') || '按市场分组'}:
</span>
<Switch
checked={groupByMarket}
onChange={setGroupByMarket}
checkedChildren={<AppstoreOutlined />}
unCheckedChildren={<UnorderedListOutlined />}
/>
</Space>
{groupByMarket && groupedOrders.length > 0 && (
<Button
type="link"
onClick={toggleAllMarkets}
size="small"
>
{expandedMarkets.size === groupedOrders.length
? (t('copyTradingOrders.collapseAll') || '折叠全部')
: (t('copyTradingOrders.expandAll') || '展开全部')}
</Button>
)}
{groupByMarket && (
<div style={{ fontSize: isMobile ? '12px' : '14px', color: '#666' }}>
{t('common.total') || '共'} {groupedTotal} {t('copyTradingOrders.markets') || '个市场'}
</div>
)}
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.refresh') || '刷新'}</Button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : groupByMarket ? (
<>
{renderGroupedView()}
{groupedOrders.length > 0 && (
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button
onClick={() => {
if (page > 1) {
setPage(page - 1)
}
}}
disabled={page <= 1}
style={{ marginRight: 8 }}
>
{t('common.previous') || '上一页'}
</Button>
<span style={{ margin: '0 16px' }}>
{t('common.pageOf') || '第'} {page} / {Math.ceil(groupedTotal / limit)} {t('common.page') || '页'}
</span>
<Button
onClick={() => {
if (page < Math.ceil(groupedTotal / limit)) {
setPage(page + 1)
}
}}
disabled={page >= Math.ceil(groupedTotal / limit)}
>
{t('common.next') || '下一页'}
</Button>
</div>
)}
</>
) : (
renderListView()
)}
</div>
)
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Modal, Form, Button, message, Radio, InputNumber, Divider, Spin, Select, Input, Space, Switch } from 'antd'
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 { SaveOutlined } from '@ant-design/icons'
import { apiService } from '../../services/api'
import type { CopyTrading, CopyTradingUpdateRequest } from '../../types'
@@ -27,6 +27,10 @@ const EditModal: React.FC<EditModalProps> = ({
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')
useEffect(() => {
if (open && copyTradingId) {
@@ -44,6 +48,24 @@ const EditModal: React.FC<EditModalProps> = ({
setCopyTrading(found)
setCopyMode(found.copyMode)
setOriginalEnabled(found.enabled)
// 解析市场截止时间(毫秒转换为小时或天)
if (found.maxMarketEndDate) {
const hours = found.maxMarketEndDate / (60 * 60 * 1000)
if (hours >= 24 && Number.isInteger(hours / 24)) {
// 大于等于24小时且是24的整数倍,使用天作为单位
setMaxMarketEndDateUnit('DAY')
setMaxMarketEndDateValue(hours / 24)
} else {
// 使用小时作为单位
setMaxMarketEndDateUnit('HOUR')
setMaxMarketEndDateValue(hours)
}
} else {
setMaxMarketEndDateValue(undefined)
setMaxMarketEndDateUnit('HOUR')
}
form.setFieldsValue({
accountId: found.accountId,
leaderId: found.leaderId,
@@ -67,9 +89,12 @@ const EditModal: React.FC<EditModalProps> = ({
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
})
// 设置关键字列表
setKeywords(found.keywords || [])
} else {
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
onClose()
@@ -90,6 +115,40 @@ const EditModal: React.FC<EditModalProps> = ({
setCopyMode(mode)
}
// 添加关键字
const handleAddKeyword = (e?: React.KeyboardEvent<HTMLInputElement>) => {
let inputValue = ''
if (e) {
const target = e.target as HTMLInputElement
inputValue = target.value.trim()
} else if (keywordInputRef.current) {
inputValue = keywordInputRef.current.input?.value?.trim() || ''
}
if (!inputValue) {
return
}
if (keywords.includes(inputValue)) {
message.warning(t('copyTradingEdit.keywordExists') || t('copyTradingAdd.keywordExists') || '关键字已存在')
return
}
const newKeywords = [...keywords, inputValue]
setKeywords(newKeywords)
if (keywordInputRef.current) {
keywordInputRef.current.input!.value = ''
}
}
// 删除关键字
const handleRemoveKeyword = (index: number) => {
const newKeywords = keywords.filter((_, i) => i !== index)
setKeywords(newKeywords)
}
const handleSubmit = async (values: any) => {
if (values.copyMode === 'FIXED') {
if (!values.fixedAmount || Number(values.fixedAmount) < 1) {
@@ -108,6 +167,15 @@ const EditModal: React.FC<EditModalProps> = ({
return
}
// 计算市场截止时间(毫秒)
let maxMarketEndDate: number | undefined
if (maxMarketEndDateValue !== undefined && maxMarketEndDateValue > 0) {
const multiplier = maxMarketEndDateUnit === 'HOUR'
? 60 * 60 * 1000 // 小时转毫秒
: 24 * 60 * 60 * 1000 // 天转毫秒
maxMarketEndDate = maxMarketEndDateValue * multiplier
}
setLoading(true)
try {
const request: CopyTradingUpdateRequest = {
@@ -133,8 +201,13 @@ const EditModal: React.FC<EditModalProps> = ({
maxPrice: values.maxPrice?.toString(),
maxPositionValue: values.maxPositionValue?.toString(),
maxPositionCount: values.maxPositionCount,
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
? keywords
: undefined,
configName: values.configName?.trim() || undefined,
pushFailedOrders: values.pushFailedOrders
pushFailedOrders: values.pushFailedOrders,
maxMarketEndDate
}
const response = await apiService.copyTrading.update(request)
@@ -178,6 +251,9 @@ const EditModal: React.FC<EditModalProps> = ({
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
keywordFilterMode: 'DISABLED'
}}
>
<Form.Item
label={t('copyTradingEdit.configName') || '配置名'}
@@ -567,6 +643,121 @@ const EditModal: React.FC<EditModalProps> = ({
/>
</Form.Item>
{/* 关键字过滤 */}
<Divider>{t('copyTradingEdit.keywordFilter') || t('copyTradingAdd.keywordFilter') || '关键字过滤'}</Divider>
<Form.Item
label={t('copyTradingEdit.keywordFilterMode') || t('copyTradingAdd.keywordFilterMode') || '过滤模式'}
name="keywordFilterMode"
tooltip={t('copyTradingEdit.keywordFilterModeTooltip') || t('copyTradingAdd.keywordFilterModeTooltip') || '选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤'}
>
<Radio.Group>
<Radio value="DISABLED">{t('copyTradingEdit.disabled') || t('copyTradingAdd.disabled') || '不启用'}</Radio>
<Radio value="WHITELIST">{t('copyTradingEdit.whitelist') || t('copyTradingAdd.whitelist') || '白名单'}</Radio>
<Radio value="BLACKLIST">{t('copyTradingEdit.blacklist') || t('copyTradingAdd.blacklist') || '黑名单'}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) =>
prevValues.keywordFilterMode !== currentValues.keywordFilterMode
}>
{({ getFieldValue }) => {
const filterMode = getFieldValue('keywordFilterMode')
if (filterMode !== 'WHITELIST' && filterMode !== 'BLACKLIST') {
return null
}
return (
<>
<Form.Item label={t('copyTradingEdit.keywords') || t('copyTradingAdd.keywords') || '关键字'}>
<Space.Compact style={{ width: '100%' }}>
<Input
ref={keywordInputRef}
placeholder={t('copyTradingEdit.keywordPlaceholder') || t('copyTradingAdd.keywordPlaceholder') || '输入关键字,按回车添加'}
onPressEnter={(e) => handleAddKeyword(e)}
/>
<Button
type="primary"
onClick={() => handleAddKeyword()}
>
{t('common.add') || '添加'}
</Button>
</Space.Compact>
{keywords.length > 0 && (
<div style={{ marginTop: 8 }}>
<Space wrap>
{keywords.map((keyword, index) => (
<Tag
key={index}
closable
onClose={() => handleRemoveKeyword(index)}
color={filterMode === 'WHITELIST' ? 'green' : 'red'}
>
{keyword}
</Tag>
))}
</Space>
</div>
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
{filterMode === 'WHITELIST'
? (t('copyTradingEdit.whitelistTooltip') || t('copyTradingAdd.whitelistTooltip') || '💡 白名单模式:只跟单包含上述任意关键字的市场标题')
: (t('copyTradingEdit.blacklistTooltip') || t('copyTradingAdd.blacklistTooltip') || '💡 黑名单模式:不跟单包含上述任意关键字的市场标题')
}
</div>
</Form.Item>
</>
)
}}
</Form.Item>
{/* 市场截止时间限制 */}
<Divider>{t('copyTradingEdit.marketEndDateFilter') || '市场截止时间限制'}</Divider>
<Form.Item
label={t('copyTradingEdit.maxMarketEndDate') || '最大市场截止时间'}
tooltip={t('copyTradingEdit.maxMarketEndDateTooltip') || '仅跟单截止时间小于设定时间的订单。例如:24 小时表示只跟单距离结算还剩24小时以内的市场'}
>
<Input.Group compact style={{ display: 'flex' }}>
<InputNumber
min={1}
max={9999}
step={1}
precision={0}
value={maxMarketEndDateValue}
onChange={(value) => setMaxMarketEndDateValue(value !== null && value !== undefined ? Math.floor(value) : undefined)}
style={{ width: '60%' }}
placeholder={t('copyTradingEdit.maxMarketEndDatePlaceholder') || '输入时间值(可选)'}
parser={(value) => {
if (!value) return 0
const num = parseInt(value.replace(/\D/g, ''), 10)
return isNaN(num) ? 0 : num
}}
formatter={(value) => {
if (!value && value !== 0) return ''
return Math.floor(value).toString()
}}
/>
<Select
value={maxMarketEndDateUnit}
onChange={(value) => setMaxMarketEndDateUnit(value)}
style={{ width: '40%' }}
placeholder={t('copyTradingEdit.timeUnit') || '单位'}
>
<Option value="HOUR">{t('copyTradingEdit.hour') || '小时'}</Option>
<Option value="DAY">{t('copyTradingEdit.day') || '天'}</Option>
</Select>
</Input.Group>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<div style={{ fontSize: 12, color: '#999' }}>
{t('copyTradingEdit.maxMarketEndDateNote') || '💡 说明:不填写表示不启用此限制'}
</div>
</Form.Item>
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
<Form.Item
@@ -67,7 +67,11 @@ const FilteredOrdersModal: React.FC<FilteredOrdersModalProps> = ({
MARKET_STATUS: { color: 'default', text: t('filteredOrdersList.filterTypes.marketStatus') || '市场状态不可交易' },
ORDERBOOK_ERROR: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败' },
ORDERBOOK_EMPTY: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
PRICE_RANGE: { color: 'purple', text: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' }
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') || '关键字过滤' }
}
const config = typeMap[filterType] || { color: 'default', text: filterType }
return <Tag color={config.color}>{config.text}</Tag>
@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react'
import { Table, Input, Button, Card, Divider, Spin } from 'antd'
import { Table, Input, Button, Card, Divider, Spin, message } from 'antd'
import { apiService } from '../../services/api'
import { formatUSDC } from '../../utils'
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard } from '../../utils'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
import { ReloadOutlined } from '@ant-design/icons'
import { ReloadOutlined, CopyOutlined } from '@ant-design/icons'
interface MatchedOrdersTabProps {
copyTradingId: string
@@ -64,34 +64,73 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
return num >= 0 ? '#3f8600' : '#cf1322'
}
const handleCopyOrderId = async (orderId: string) => {
const success = await copyToClipboard(orderId)
if (success) {
message.success(t('common.copySuccess') || '已复制到剪贴板')
} else {
message.error(t('common.copyFailed') || '复制失败')
}
}
const columns = [
{
title: t('copyTradingOrders.sellOrderId') || '卖出订单ID',
dataIndex: 'sellOrderId',
key: 'sellOrderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
width: isMobile ? 120 : 180,
render: (text: string) => {
const isAuto = isAutoGeneratedOrderId(text)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
{!isAuto && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(text)}
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
title={t('common.copy') || '复制'}
/>
)}
</div>
)
}
},
{
title: t('copyTradingOrders.buyOrderId') || '买入订单ID',
dataIndex: 'buyOrderId',
key: 'buyOrderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
width: isMobile ? 120 : 180,
render: (text: string) => {
const isAuto = isAutoGeneratedOrderId(text)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
{!isAuto && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(text)}
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
title={t('common.copy') || '复制'}
/>
)}
</div>
)
}
},
{
title: t('copyTradingOrders.matchedQuantity') || '匹配数量',
@@ -206,22 +245,63 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
bodyStyle={{ padding: '16px' }}
>
<div style={{ marginBottom: '12px' }}>
{(order.marketTitle || order.marketId) && (
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
{order.marketTitle ? (
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
{order.marketTitle}
</div>
) : null}
{order.marketId && (
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
</div>
)}
</div>
)}
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellOrderId') || '卖出订单ID'}</div>
<div style={{
fontSize: '13px',
fontWeight: '500',
fontFamily: 'monospace',
marginBottom: '8px'
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}
<span>{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}</span>
{!isAutoGeneratedOrderId(order.sellOrderId) && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(order.sellOrderId)}
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
title={t('common.copy') || '复制'}
/>
)}
</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyOrderId') || '买入订单ID'}</div>
<div style={{
fontSize: '13px',
fontWeight: '500',
fontFamily: 'monospace'
fontFamily: 'monospace',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}
<span>{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}</span>
{!isAutoGeneratedOrderId(order.buyOrderId) && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(order.buyOrderId)}
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
title={t('common.copy') || '复制'}
/>
)}
</div>
</div>
@@ -1,14 +1,14 @@
import { useEffect, useState } from 'react'
import { Table, Tag, Select, Input, Button, Card, Divider, Spin } from 'antd'
import { Table, Tag, Select, Input, Button, Card, Divider, Spin, message, Switch, Space } from 'antd'
import { apiService } from '../../services/api'
import { formatUSDC } from '../../utils'
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard, getPolymarketUrl } from '../../utils'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { SellOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
import type { SellOrderInfo, OrderTrackingRequest, OrderTrackingListResponse, MarketGroupedOrdersRequest, MarketGroupedOrdersResponse, MarketOrderGroup } from '../../types'
const { Option } = Select
import { ReloadOutlined } from '@ant-design/icons'
import { ReloadOutlined, CopyOutlined, DownOutlined, UpOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons'
interface SellOrdersTabProps {
copyTradingId: string
@@ -27,31 +27,53 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
marketId?: string
side?: string
}>({})
const [groupByMarket, setGroupByMarket] = useState(false) // 是否按市场分组
const [expandedMarkets, setExpandedMarkets] = useState<Set<string>>(new Set()) // 展开的市场ID集合
const [groupedOrders, setGroupedOrders] = useState<MarketOrderGroup[]>([]) // 分组数据(从后端获取)
const [groupedTotal, setGroupedTotal] = useState(0) // 分组总数(市场数量)
useEffect(() => {
if (copyTradingId && active) {
fetchOrders()
}
}, [copyTradingId, active, page, limit, filters])
}, [copyTradingId, active, page, limit, filters, groupByMarket])
const fetchOrders = async () => {
if (!copyTradingId) return
setLoading(true)
try {
const request: OrderTrackingRequest = {
copyTradingId: parseInt(copyTradingId),
type: 'sell',
page,
limit,
...filters
}
if (groupByMarket) {
// 调用分组接口
const request: MarketGroupedOrdersRequest = {
copyTradingId: parseInt(copyTradingId),
type: 'sell',
page,
limit
}
const response = await apiService.orderTracking.list(request)
if (response.data.code === 0 && response.data.data) {
const data = response.data.data as OrderTrackingListResponse
setOrders((data.list || []) as SellOrderInfo[])
setTotal(data.total || 0)
const response = await apiService.orderTracking.listGroupedByMarket(request)
if (response.data.code === 0 && response.data.data) {
const data = response.data.data as MarketGroupedOrdersResponse
setGroupedOrders(data.list || [])
setGroupedTotal(data.total || 0)
}
} else {
// 调用普通列表接口
const request: OrderTrackingRequest = {
copyTradingId: parseInt(copyTradingId),
type: 'sell',
page,
limit,
...filters
}
const response = await apiService.orderTracking.list(request)
if (response.data.code === 0 && response.data.data) {
const data = response.data.data as OrderTrackingListResponse
setOrders((data.list || []) as SellOrderInfo[])
setTotal(data.total || 0)
}
}
} catch (error: any) {
console.error('获取卖出订单列表失败:', error)
@@ -66,20 +88,66 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
return num >= 0 ? '#3f8600' : '#cf1322'
}
const handleCopyOrderId = async (orderId: string) => {
const success = await copyToClipboard(orderId)
if (success) {
message.success(t('common.copySuccess') || '已复制到剪贴板')
} else {
message.error(t('common.copyFailed') || '复制失败')
}
}
// 切换市场展开/折叠
const toggleMarket = (marketId: string) => {
const newExpanded = new Set(expandedMarkets)
if (newExpanded.has(marketId)) {
newExpanded.delete(marketId)
} else {
newExpanded.add(marketId)
}
setExpandedMarkets(newExpanded)
}
// 展开/折叠全部
const toggleAllMarkets = () => {
if (expandedMarkets.size === groupedOrders.length) {
setExpandedMarkets(new Set())
} else {
setExpandedMarkets(new Set(groupedOrders.map(g => g.marketId)))
}
}
// 分组默认全部折叠(不自动展开)
const columns = [
{
title: t('copyTradingOrders.orderId') || '订单ID',
dataIndex: 'orderId',
key: 'orderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
width: isMobile ? 120 : 180,
render: (text: string) => {
const isAuto = isAutoGeneratedOrderId(text)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
{!isAuto && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(text)}
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
title={t('common.copy') || '复制'}
/>
)}
</div>
)
}
},
{
title: t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID',
@@ -99,15 +167,42 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
title: t('copyTradingOrders.market') || '市场',
dataIndex: 'marketId',
key: 'marketId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
width: isMobile ? 120 : 200,
render: (text: string, record: SellOrderInfo) => {
const marketUrl = getPolymarketUrl(record.marketSlug, record.eventSlug, record.marketCategory, record.marketId)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{record.marketTitle ? (
marketUrl ? (
<a
href={marketUrl}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: isMobile ? 11 : 12,
fontWeight: 500,
color: '#1890ff',
textDecoration: 'none',
cursor: 'pointer'
}}
>
{record.marketTitle}
</a>
) : (
<span style={{ fontSize: isMobile ? 11 : 12, fontWeight: 500 }}>
{record.marketTitle}
</span>
)
) : null}
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 10 : 11, color: '#999' }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
</div>
)
}
},
{
title: t('copyTradingOrders.side') || '方向',
@@ -181,34 +276,182 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
}
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/>
// 渲染分组视图
const renderGroupedView = () => {
if (groupedOrders.length === 0) {
return (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
{t('copyTradingOrders.noSellOrders') || '暂无卖出订单'}
</div>
)
}
<Select
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.side}
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
>
<Option value="0">YES</Option>
<Option value="1">NO</Option>
<Option value="YES">YES</Option>
<Option value="NO">NO</Option>
</Select>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{groupedOrders.map((group) => {
const isExpanded = expandedMarkets.has(group.marketId)
const marketDisplayName = group.marketTitle || group.marketId.slice(0, 8) + '...' + group.marketId.slice(-6)
const marketUrl = getPolymarketUrl(group.marketSlug, group.eventSlug, group.marketCategory, group.marketId)
const pnlColor = getPnlColor(group.stats.totalPnl || '0')
const orders = group.orders as SellOrderInfo[]
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.refresh') || '刷新'}</Button>
return (
<Card
key={group.marketId}
style={{
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
}}
bodyStyle={{ padding: '16px' }}
>
{/* 分组头部 */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
padding: '8px 0',
marginBottom: isExpanded ? '12px' : 0
}}
onClick={() => toggleMarket(group.marketId)}
>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
{marketUrl ? (
<a
href={marketUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={{
fontSize: isMobile ? '14px' : '16px',
fontWeight: 'bold',
color: '#1890ff',
textDecoration: 'none',
cursor: 'pointer'
}}
>
{marketDisplayName}
</a>
) : (
<span style={{ fontSize: isMobile ? '14px' : '16px', fontWeight: 'bold' }}>
{marketDisplayName}
</span>
)}
<Tag color="success">{t('copyTradingOrders.allFullyMatched') || '全部成交'}</Tag>
</div>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}>
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span>
{group.stats.totalPnl && (
<span style={{ color: pnlColor, fontWeight: 500 }}>
{t('copyTradingOrders.totalPnl') || '总盈亏'}: {formatUSDC(group.stats.totalPnl)} USDC
</span>
)}
</div>
</div>
<Button
type="text"
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
onClick={(e) => {
e.stopPropagation()
toggleMarket(group.marketId)
}}
/>
</div>
{/* 订单列表 */}
{isExpanded && (
<>
<Divider style={{ margin: '12px 0' }} />
{isMobile ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{orders.map((order) => {
const date = new Date(order.createdAt)
const formattedDate = date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
return (
<Card
key={order.orderId}
size="small"
style={{
borderRadius: '8px',
border: '1px solid #f0f0f0',
backgroundColor: '#fafafa'
}}
bodyStyle={{ padding: '12px' }}
>
<div style={{ marginBottom: '8px' }}>
<div style={{
fontSize: '13px',
fontWeight: 'bold',
marginBottom: '6px',
fontFamily: 'monospace',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span>{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}</span>
{!isAutoGeneratedOrderId(order.orderId) && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(order.orderId)}
style={{ padding: 0, height: 'auto', fontSize: '11px' }}
title={t('common.copy') || '复制'}
/>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag style={{ fontSize: '11px' }}>{displaySide}</Tag>
</div>
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
<div>{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}</div>
<div>{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC</div>
<div style={{ color: getPnlColor(order.realizedPnl), fontWeight: 500 }}>
{t('copyTradingOrders.realizedPnl') || '已实现盈亏'}: {formatUSDC(order.realizedPnl)} USDC
</div>
<div style={{ color: '#999', marginTop: '4px' }}>{formattedDate}</div>
</div>
</Card>
)
})}
</div>
) : (
<Table
columns={columns}
dataSource={orders}
rowKey="orderId"
pagination={false}
size="small"
/>
)}
</>
)}
</Card>
)
})}
</div>
)
}
{isMobile ? (
// 渲染列表视图
const renderListView = () => {
if (isMobile) {
return (
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
@@ -247,9 +490,22 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
fontSize: '14px',
fontWeight: 'bold',
marginBottom: '8px',
fontFamily: 'monospace'
fontFamily: 'monospace',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}
<span>{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}</span>
{!isAutoGeneratedOrderId(order.orderId) && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyOrderId(order.orderId)}
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
title={t('common.copy') || '复制'}
/>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag>{displaySide}</Tag>
@@ -287,7 +543,34 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
{order.marketTitle ? (
(() => {
const marketUrl = getPolymarketUrl(order.marketSlug, order.eventSlug, order.marketCategory, order.marketId)
return marketUrl ? (
<a
href={marketUrl}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: '13px',
fontWeight: '500',
marginBottom: '4px',
color: '#1890ff',
textDecoration: 'none',
cursor: 'pointer',
display: 'block'
}}
>
{order.marketTitle}
</a>
) : (
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
{order.marketTitle}
</div>
)
})()
) : null}
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
</div>
@@ -304,7 +587,9 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
</div>
)}
</div>
) : (
)
} else {
return (
<Table
columns={columns}
dataSource={orders}
@@ -322,6 +607,105 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
}
}}
/>
)
}
}
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<Input
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/>
<Select
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.side}
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
>
<Option value="0">YES</Option>
<Option value="1">NO</Option>
<Option value="YES">YES</Option>
<Option value="NO">NO</Option>
</Select>
<Space>
<span style={{ fontSize: isMobile ? '12px' : '14px' }}>
{t('copyTradingOrders.groupByMarket') || '按市场分组'}:
</span>
<Switch
checked={groupByMarket}
onChange={setGroupByMarket}
checkedChildren={<AppstoreOutlined />}
unCheckedChildren={<UnorderedListOutlined />}
/>
</Space>
{groupByMarket && groupedOrders.length > 0 && (
<Button
type="link"
onClick={toggleAllMarkets}
size="small"
>
{expandedMarkets.size === groupedOrders.length
? (t('copyTradingOrders.collapseAll') || '折叠全部')
: (t('copyTradingOrders.expandAll') || '展开全部')}
</Button>
)}
{groupByMarket && (
<div style={{ fontSize: isMobile ? '12px' : '14px', color: '#666' }}>
{t('common.total') || '共'} {groupedTotal} {t('copyTradingOrders.markets') || '个市场'}
</div>
)}
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.refresh') || '刷新'}</Button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : groupByMarket ? (
<>
{renderGroupedView()}
{groupedOrders.length > 0 && (
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button
onClick={() => {
if (page > 1) {
setPage(page - 1)
}
}}
disabled={page <= 1}
style={{ marginRight: 8 }}
>
{t('common.previous') || '上一页'}
</Button>
<span style={{ margin: '0 16px' }}>
{t('common.pageOf') || '第'} {page} / {Math.ceil(groupedTotal / limit)} {t('common.page') || '页'}
</span>
<Button
onClick={() => {
if (page < Math.ceil(groupedTotal / limit)) {
setPage(page + 1)
}
}}
disabled={page >= Math.ceil(groupedTotal / limit)}
>
{t('common.next') || '下一页'}
</Button>
</div>
)}
</>
) : (
renderListView()
)}
</div>
)
@@ -65,6 +65,10 @@ const FilteredOrdersList: React.FC = () => {
'ORDERBOOK_ERROR': { color: 'default', label: t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败' },
'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') || '未知原因' }
}
const config = typeMap[type] || typeMap['UNKNOWN']
+4 -4
View File
@@ -582,9 +582,9 @@ const PositionList: React.FC = () => {
)}
<div style={{ flex: 1, minWidth: 0 }}>
{position.marketTitle ? (
position.marketSlug ? (
(position.eventSlug || position.marketSlug) ? (
<a
href={`https://polymarket.com/event/${position.marketSlug}`}
href={`https://polymarket.com/event/${position.eventSlug || position.marketSlug}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
@@ -838,8 +838,8 @@ const PositionList: React.FC = () => {
dataIndex: 'marketTitle',
key: 'marketTitle',
render: (text: string | undefined, record: AccountPosition) => {
const url = record.marketSlug
? `https://polymarket.com/event/${record.marketSlug}`
const url = record.eventSlug || record.marketSlug
? `https://polymarket.com/event/${record.eventSlug || record.marketSlug}`
: null
const handleTitleClick = (e: React.MouseEvent) => {
+7 -1
View File
@@ -472,7 +472,13 @@ export const apiService = {
* //
*/
list: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/orders/tracking', data)
apiClient.post<ApiResponse<any>>('/copy-trading/orders/tracking', data),
/**
* /
*/
listGroupedByMarket: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/orders/grouped-by-market', data)
},
/**
+73 -1
View File
@@ -210,9 +210,13 @@ export interface CopyTrading {
// 最大仓位配置
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
configName?: string // 配置名(可选,但提供时必须非空)
pushFailedOrders: boolean // 推送失败订单(默认关闭)
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
createdAt: number
updatedAt: number
}
@@ -256,9 +260,13 @@ export interface CopyTradingCreateRequest {
// 最大仓位配置
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
configName?: string // 配置名(可选,但提供时必须非空)
pushFailedOrders?: boolean // 推送失败订单(可选)
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
}
/**
@@ -290,9 +298,13 @@ export interface CopyTradingUpdateRequest {
// 最大仓位配置
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
configName?: string // 配置名(可选,但提供时必须非空)
pushFailedOrders?: boolean // 推送失败订单(可选)
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
}
/**
@@ -372,7 +384,8 @@ export interface AccountPosition {
proxyAddress: string
marketId: string
marketTitle?: string
marketSlug?: string
marketSlug?: string // 显示用的 slug
eventSlug?: string // 跳转用的 slug(从 events[0].slug 获取)
marketIcon?: string // 市场图标 URL
side: string // 结果名称(如 "YES", "NO", "Pakistan" 等)
outcomeIndex?: number // 结果索引(0, 1, 2...),用于计算 tokenId
@@ -627,6 +640,10 @@ export interface BuyOrderInfo {
orderId: string
leaderTradeId: string
marketId: string
marketTitle?: string // 市场名称
marketSlug?: string // 市场 slug(用于显示)
eventSlug?: string // 跳转用的 slug(从 events[0].slug 获取)
marketCategory?: string // 市场分类(sports, crypto 等)
side: string
quantity: string
price: string
@@ -644,6 +661,10 @@ export interface SellOrderInfo {
orderId: string
leaderTradeId: string
marketId: string
marketTitle?: string // 市场名称
marketSlug?: string // 市场 slug(用于显示)
eventSlug?: string // 跳转用的 slug(从 events[0].slug 获取)
marketCategory?: string // 市场分类(sports, crypto 等)
side: string
quantity: string
price: string
@@ -658,6 +679,11 @@ export interface SellOrderInfo {
export interface MatchedOrderInfo {
sellOrderId: string
buyOrderId: string
marketId?: string // 市场ID
marketTitle?: string // 市场名称
marketSlug?: string // 市场 slug(用于显示)
eventSlug?: string // 跳转用的 slug(从 events[0].slug 获取)
marketCategory?: string // 市场分类(sports, crypto 等)
matchedQuantity: string
buyPrice: string
sellPrice: string
@@ -690,6 +716,52 @@ export interface OrderTrackingRequest {
buyOrderId?: string
}
/**
*
*/
export interface MarketGroupedOrdersRequest {
copyTradingId: number
type: 'buy' | 'sell'
page?: number
limit?: number
}
/**
*
*/
export interface MarketOrderStats {
count: number
totalAmount: string // 总金额
totalPnl?: string // 总盈亏(买入订单未实现盈亏,此字段为空)
fullyMatched: boolean // 是否全部成交
fullyMatchedCount: number // 完全成交的订单数
partiallyMatchedCount: number // 部分成交的订单数
filledCount: number // 未成交的订单数
}
/**
*
*/
export interface MarketOrderGroup {
marketId: string
marketTitle?: string
marketSlug?: string // 显示用的 slug
eventSlug?: string // 跳转用的 slug(从 events[0].slug 获取)
marketCategory?: string
stats: MarketOrderStats
orders: BuyOrderInfo[] | SellOrderInfo[] // 订单列表
}
/**
*
*/
export interface MarketGroupedOrdersResponse {
list: MarketOrderGroup[]
total: number // 市场总数
page: number
limit: number
}
/**
*
*/
+79
View File
@@ -83,3 +83,82 @@ export {
getGitHubTagUrl
} from './version'
/**
* ID是否为自动生成的
* ID通常以 "AUTO_" "AUTO_FIFO_"
* @param orderId - ID
* @returns ID true false
*/
export const isAutoGeneratedOrderId = (orderId: string | undefined | null): boolean => {
if (!orderId) return false
return orderId.startsWith('AUTO_') || orderId.startsWith('AUTO_FIFO_')
}
/**
* Polymarket URL
* moneyline moneyline
* moneyline
* @param marketSlug - slug
* @param eventSlug - slug events[0].slug 使
* @param marketCategory - sports, crypto
* @param marketId - ID
* @param isMoneyline - moneyline
* @returns Polymarket URL null
*/
export const getPolymarketUrl = (
marketSlug?: string | null,
eventSlug?: string | null, // 跳转用的 slug(优先使用)
_marketCategory?: string | null, // 保留参数以便未来使用
marketId?: string | null,
isMoneyline?: boolean
): string | null => {
// 优先使用 eventSlug(跳转用的 slug
const slug = eventSlug || marketSlug
if (slug) {
// 如果是 moneyline 市场,跳转到 moneyline 页面
if (isMoneyline === true) {
return `https://polymarket.com/event/${slug}/moneyline`
}
// 其他市场跳转到普通市场页面
return `https://polymarket.com/event/${slug}`
}
// 如果没有 slug,使用 marketId(作为后备)
if (marketId && marketId.startsWith('0x')) {
return `https://polymarket.com/condition/${marketId}`
}
return null
}
/**
*
* @param text -
* @returns Promise<boolean> - true false
*/
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
return true
} else {
// 降级方案:使用传统的 document.execCommand
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
return successful
}
} catch (error) {
console.error('复制到剪贴板失败:', error)
return false
}
}