Compare commits

..

2 Commits

Author SHA1 Message Date
WrBug 10b374b4d9 Merge branch 'pre_release' into feature_nba_quant
# Conflicts:
#	backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt
#	frontend/src/components/Layout.tsx
#	frontend/src/types/index.ts
2026-01-03 19:53:03 +08:00
WrBug e9e1111df9 feat: 实现 NBA 量化交易系统
- 后端实现:
  - 实现 NBA 比赛数据服务,从 Polymarket API 获取数据
  - 实现数据库存储和增量拉取逻辑(优先从 DB 获取,数据不足时增量拉取)
  - 使用 sports_market_types 参数直接筛选 moneyline 类型
  - 实现分页拉取逻辑(基于 gameStartTime 和 createdAt)
  - 移除 nba_markets 相关的外键约束(V12 迁移)
  - 修复数据拉取逻辑:超过 3 天的数据不拉取

- 前端实现:
  - 实现策略创建/编辑/列表页面
  - 实现交易信号展示页面和统计页面
  - 修复重复请求问题(使用 useCallback 包装 fetchGames)
  - 支持选择单场比赛进行配置
  - 使用西8区时间格式化显示

- 数据库:
  - 创建 NBA 量化交易相关表(V11 迁移)
  - 移除外键约束(V12 迁移)

- 文档:
  - 添加产品需求文档、技术方案、算法文档等
2025-12-13 08:05:08 +08:00
232 changed files with 17597 additions and 28118 deletions
+595 -172
View File
@@ -5,62 +5,391 @@ path: backend/**
# 后端开发规范
## 核心原则
- **禁止**在代码中添加 TODO/FIXME/XXX 注释
- **禁止**返回 mock 数据或硬编码的假数据
- **禁止**在 API 调用失败时返回默认值作为 fallback
- 所有功能必须完整实现,确保代码可以正常运行
## 代码完成规范
### 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 管理等)
## 项目范围
- **平台**: 仅支持 Polymarket
### 平台支持
- **仅支持**: Polymarket 平台
- **不支持**: 其他预测市场平台(如 Kalshi 等)
### 分类支持
- **仅支持**:
- `sports`: 体育相关市场
- `crypto`: 加密货币相关市场
- **不支持**: 其他分类(如 politics、entertainment 等)
### 分类验证
- 所有涉及分类的接口、实体、服务必须验证分类参数
- 分类参数只能是 `sports` 或 `crypto`
- 无效分类应返回明确的错误提示
## 包名规范
- **包名**: `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, // 禁止使用
// ...
)
```
## 配置文件规范
### 配置文件格式
- **必须**使用 `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`
## 代码规范
### Controller规范
- Controller 方法**禁止**使用 `suspend`
- 如需调用 suspend 方法,使用 `runBlocking`(最小化使用)
- 只作用于 suspend 方法调用
```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` 方法
- 使用 `@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`
- 使用 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
}
```
### 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, // ✅ 正确:使用可空类型
val id: Long? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(), // ✅ 使用 Long 时间戳
@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 // ✅ 使用 BigDecimal
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()
)
```
**规则**:
- ID 字段必须使用 `Long? = null`
- 时间字段使用 `Long` 时间戳(毫秒)
- 数值字段使用 `BigDecimal`
- **禁止**使用 `LocalDateTime` 或 `Double`
## 数值计算规范
## 配置文件规范
- **必须**使用 `application.properties` 格式(禁止 `application.yml`
- 使用 `${ENV_VAR:default}` 引用环境变量
### BigDecimal使用
- 所有数值计算必须使用 `BigDecimal`
- 使用 `String` 存储数值
- 使用扩展函数进行安全转换和比较
## 代码规范
```kotlin
// 使用扩展函数
val amount = "0.5".toSafeBigDecimal()
val total = amount.add("0.4".toSafeBigDecimal())
### Controller
- **禁止**使用 `suspend`
- 使用 `runBlocking` 调用 suspend 方法
- 统一使用 `@PostMapping`
// 比较
if (total.lt(BigDecimal.ONE)) {
// 业务逻辑
}
```
### Service
- 可以使用 `suspend` 方法
- 使用 `@Transactional` 管理事务
- 使用构造函数注入依赖
## 时间字段规范
### Repository
- 继承 `JpaRepository<Entity, Long>`
- 使用 Spring Data JPA 方法命名规范
### 时间戳使用
- 所有时间字段使用 `Long` 类型存储毫秒级时间戳
- **禁止**使用 `LocalDateTime` 或其他时间类型
## API 接口规范
```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,
@@ -69,7 +398,77 @@ data class ExampleEntity(
}
```
### 错误码范围
- **响应字段说明**:
- `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`: 认证/权限错误
@@ -77,131 +476,7 @@ data class ExampleEntity(
- `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 管理
- 订单同步与执行
- 跟单配置管理
- 风险控制
- 跟单记录与统计
详细错误码定义参见需求文档
## 禁止事项
@@ -209,25 +484,173 @@ return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, messageSource)
- ❌ 禁止使用 `!!` 除非有明确原因
- ❌ 禁止忽略异常
- ❌ 禁止硬编码配置值
- ❌ 禁止提交敏感信息到 Git
- ❌ 禁止提交敏感信息到Git
- ❌ Controller 方法禁止使用 `suspend`
- ❌ 实体类ID禁止使用 `Long = 0`
- ❌ **禁止直接返回 mock 数据或硬编码的假数据**
- ❌ **禁止在 API 调用失败时返回 mock 数据作为默认值**
- ❌ **所有返回的数据必须来自真实的 API 调用或数据库查询**
### 配置
- ❌ 禁止使用 `application.yml`
- ❌ 禁止在代码中硬编码配置值
### 类型
- ❌ 禁止使用 `Double` 进行数值计算
- ❌ 禁止使用 `LocalDateTime` 存储时间
- ❌ 禁止实体类 ID 使用非空默认值
- ❌ 禁止直接使用 `Gson`(必须使用 `JsonUtils`
- ❌ 禁止 data class 字段缺少默认值
- ❌ 禁止实体类ID使用非空默认值
### API 接口
- ❌ 禁止使用 GET/PUT/DELETE(统一使用 POST
### API接口
- ❌ 禁止使用GETPUTDELETE等方法(统一使用POST
- ❌ 禁止返回不符合统一格式的响应
- ❌ 禁止在响应中直接返回 Map 类型
- ❌ 禁止在响应中直接返回Map类型(使用data class
### Side 判断
- ❌ 禁止使用 "YES"/"NO" 字符串判断 side
- ✅ 必须使用 `outcomeIndex` 判断
### Side 判断规范
- ❌ **禁止使用 "YES""NO" 字符串判断 side**
- ✅ **必须使用 `outcomeIndex` 判断方向**0 = 第一个 outcome1 = 第二个 outcome,以此类推)
- ✅ 如果必须使用 side 字符串,应该从市场的 outcomes 数组中获取,而不是硬编码 "YES"/"NO"
- ✅ 对于二元市场的价格转换,应该通过 `outcomeIndex` 判断是否为第二个 outcomeindex = 1),而不是判断 side 是否为 "NO"
### 数据源
-禁止直接返回 mock 数据
- ❌ 禁止在 API 调用失败时返回 mock 数据
- ✅ 所有数据必须来自真实 API 或数据库查询
```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
))
}
// ... 业务逻辑
}
}
```
+232 -68
View File
@@ -5,41 +5,107 @@ path: frontend/**
# 前端开发规范
## 核心原则
- **禁止**在代码中添加 TODO/FIXME/XXX 注释
- **禁止**使用 `any` 类型
- **禁止**硬编码中文或英文文本
- **必须**支持移动端和桌面端
## 代码完成规范
### 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' }
}
}
```
## 技术栈
- **框架**: React + TypeScript
- **UI 库**: Ant Design(移动端使用 Ant Design Mobile
- **HTTP 客户端**: axios
- **状态管理**: Zustand
- **国际化**: react-i18next
- **UI库**: Ant Design 或 Material-UI(推荐 Ant Design Mobile 用于移动端
- **HTTP客户端**: axios
- **状态管理**: Zustand 或 Redux
- **响应式设计**: 必须支持移动端和桌面端
## 移动端适配
## 移动端适配要求
### 响应式断点
### 响应式设计
- **必须支持移动端和桌面端**
- 使用响应式布局(Responsive Design
- 移动端优先(Mobile First)设计原则
- 支持触摸操作和手势
### 断点设置
- **移动端**: < 768px
- **平板**: 768px - 1024px
- **桌面端**: > 1024px
### 适配要求
- 使用响应式布局(Mobile First
- 导航栏:移动端抽屉菜单,桌面端顶部导航
- 列表:移动端卡片布局,桌面端表格布局
- 按钮:移动端不小于 44x44px
- 支持触摸操作和手势
### UI 组件适配
- 使用 Ant Design 的响应式组件
- 移动端使用 Ant Design Mobile(如果使用 Ant Design
- 表格使用虚拟滚动或分页(移动端性能优化)
- 表单使用移动端友好的输入组件
### 布局适配
- 导航栏:移动端使用抽屉菜单,桌面端使用顶部导航
- 列表:移动端使用卡片布局,桌面端使用表格布局
- 按钮:移动端按钮尺寸不小于 44x44px(触摸友好)
- 间距:移动端使用更大的间距,提高可点击区域
### 性能优化
- 图片懒加载
- 代码分割(Code Splitting
- 移动端减少动画效果
- 使用 CSS 媒体查询优化样式
## 代码规范
### 组件
### 组件规范
- 使用函数式组件
- 使用 TypeScript 类型定义
- 组件文件使用 PascalCase 命名
@@ -56,18 +122,24 @@ export const MarketCard: React.FC<MarketProps> = ({ marketId, platform }) => {
};
// ❌ 错误
export const marketCard = (props: any) => { }
export const marketCard = (props: any) => { // 禁止使用any
// ...
};
```
### API 调用
- 使用 axios
### API调用规范
- 使用 axios 进行 HTTP 请求
- 统一错误处理
- 使用 TypeScript 定义响应类型
```typescript
// API服务
import axios from 'axios';
interface Market {
id: string;
marketId: string;
platform: string;
title: string;
}
@@ -75,16 +147,24 @@ 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
### 状态管理规范
- 使用 Zustand 或 Redux 管理全局状态
- 本地状态使用 `useState`
- 复杂状态使用 `useReducer`
```typescript
// Zustand Store示例
import { create } from 'zustand';
interface MarketStore {
markets: Market[];
setMarkets: (markets: Market[]) => void;
@@ -96,64 +176,80 @@ export const useMarketStore = create<MarketStore>((set) => ({
}));
```
### USDC 金额格式化
**必须**使用 `formatUSDC` 函数(从 `../utils` 导入)
### USDC 金额格式化规范
- **必须**使用 `formatUSDC` 函数格式化所有 USDC 金额显示
- **禁止**直接使用 `toFixed()` 或 `parseFloat().toFixed()` 格式化 USDC
- **禁止**硬编码小数位数格式化 USDC
- 所有 USDC 金额显示必须统一使用 `formatUSDC` 函数
#### formatUSDC 函数说明
- **位置**: `src/utils/index.ts`
- **功能**: 格式化 USDC 金额,最多显示 4 位小数,自动去除尾随零(截断,不四舍五入)
- **参数**: `value: string | number | undefined | null`
- **返回值**: 格式化后的字符串,如果值为空或无效则返回 `'-'`
#### 使用示例
```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"
/>
// ❌ 错误
const balance = parseFloat(value).toFixed(4)
// ❌ 错误:直接使用 toFixed
const balance = parseFloat(value).toFixed(4) // 禁止
// ❌ 错误:硬编码格式化
const balance = `${parseFloat(value).toFixed(2)} USDC` // 禁止
// ❌ 错误:使用自定义格式化函数
const formatBalance = (value: string) => parseFloat(value).toFixed(4) // 禁止
```
**格式化规则**:
- 最多显示 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)` => `"-"`
## 多语言规范
### 使用方式
**必须**使用 `useTranslation` Hook
#### 工具函数统一导出
- 所有工具函数统一从 `src/utils/index.ts` 导出
- **必须**从 `../utils` 导入工具函数,而不是从具体文件导入
- `ethers.ts` 中的函数也会在 `index.ts` 中统一导出
```typescript
// ✅ 正确
import { useTranslation } from 'react-i18next'
// ✅ 正确:从 utils 统一导入
import { formatUSDC, getAddressFromPrivateKey, isValidWalletAddress } from '../utils'
const MyComponent: React.FC = () => {
const { t } = useTranslation()
return (
<div>
<h1>{t('page.title')}</h1>
<Button>{t('common.save')}</Button>
</div>
)
}
// ❌ 错误:硬编码文本
<h1>页面标题</h1>
<Button>保存</Button>
// ❌ 错误:从具体文件导入
import { formatUSDC } from '../utils/index'
import { getAddressFromPrivateKey } from '../utils/ethers'
```
### 语言文件
- 位置: `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')`
## 移动端适配示例
### 响应式布局
@@ -171,6 +267,29 @@ 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';
@@ -197,18 +316,63 @@ const ResponsiveTable: React.FC = () => {
### 代码质量
- ❌ 禁止使用 `any` 类型
- ❌ 禁止忽略错误处理
- ❌ 禁止硬编码 API 地址
- ❌ 禁止硬编码API地址
- ❌ 禁止在组件中直接使用 `fetch`
- ❌ 禁止忽略移动端适配
### 类型安全
- ❌ 禁止使用 `any`
- ❌ 禁止忽略 TypeScript 类型检查
- ❌ 禁止使用 `@ts-ignore` 除非有明确原因
### 移动端适配
- ❌ 禁止固定宽度布局
- ❌ 禁止使用过小的触摸目标(< 44x44px
- ❌ 禁止使用桌面端专用交互(如 hover
- ❌ 禁止忽略移动端性能优化
- ❌ 禁止使用桌面端专用的交互方式(如 hover)
### 多语言
- ❌ 禁止硬编码中文或英文文本
- ❌ 禁止绕过 `useTranslation` 直接显示文本
### 多语言使用规范
- **必须**使用多语言(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')` 获取翻译
-39
View File
@@ -1,39 +0,0 @@
# Cursor Skills 目录结构
本目录遵循 [Cursor Agent Skills 规范](https://cursor.com/cn/docs/context/skills):每个技能为一个**文件夹**,内含 `SKILL.md` 及可选的 `scripts/``references/``assets/`
## 目录结构
```
.cursor/skills/
├── frontend/ # 前端相关 skill 分组
│ └── check-i18n-keys/ # 单个 skill(文件夹名 = name
│ ├── SKILL.md # 必填,技能定义与指令
│ └── scripts/
│ ├── check-i18n-keys.ts
│ └── package.json
├── backend/ # 后端相关 skill
└── common/ # 通用 skill
```
- **SKILL.md**YAML frontmatter`name``description` 必填,`name` 须与父文件夹名一致、小写连字符)+ 给 Agent 的详细指令。
- **scripts/**Agent 可执行的脚本,在 SKILL.md 中用相对路径引用。
## 添加新 Skill
1. **确定分组**:在 `frontend/``backend/``common/` 下新建**以技能名命名的文件夹**(仅小写、数字、连字符,如 `check-i18n-keys`)。
2. **创建 SKILL.md**:在该文件夹内创建 `SKILL.md`(大写),frontmatter 中 `name` 必须与文件夹名一致。
3. **可选 scripts/**:在技能文件夹内建 `scripts/`,放入可执行脚本;在 SKILL.md 正文中写明运行命令(如 `cd .cursor/skills/.../scripts && npm run xxx`)。
## 示例
- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key。
## 运行 check-i18n-keys
```bash
cd .cursor/skills/frontend/check-i18n-keys/scripts
npm install
npm run check-i18n
```
@@ -1,36 +0,0 @@
---
name: check-i18n-keys
description: 检查前端多语言 key 完整性。当用户要求检查 i18n、多语言缺失、翻译 key 或运行 check-i18n 时使用。
---
# Check I18n Keys
检查前端代码中使用的 i18n key 是否在所有语言文件(zh-CN、zh-TW、en)中存在,并报告缺失或语言间不一致的 key。
## 使用时机
- 用户要求「检查多语言」「检查 i18n」「扫一下 key」「多语言缺失」时
- 用户要求运行多语言检查或执行 check-i18n 时
- 在修改或新增前端文案后,需要确认三语言 key 一致时
## 指令
1. **运行检查脚本**(在项目仓库根目录下执行):
```bash
cd .cursor/skills/frontend/check-i18n-keys/scripts && npm install && npm run check-i18n
```
首次运行需先 `npm install`,之后可直接 `npm run check-i18n`。
2. **脚本行为**
- 扫描 `frontend/` 下所有 `.ts`、`.tsx`、`.js`、`.jsx`(排除 node_modules、dist、build、*.d.ts
- 提取代码中 `t('key')` / `t("key")` 的 key
- 与 `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 对比
- 报告:代码中使用但某语言 JSON 缺失的 key;某语言有而另一语言没有的 key(不一致)
3. **结果处理**
- 若有缺失或不一致,退出码为 1;可根据报告在对应 `common.json` 中补全 key
- 脚本路径:`scripts/check-i18n-keys.ts`(相对本 skill 根目录)
## 可选目录说明
- `scripts/`:可执行检查脚本,Agent 按上述命令调用。
@@ -1,3 +0,0 @@
node_modules/
*.log
.DS_Store
@@ -1,302 +0,0 @@
#!/usr/bin/env node
/**
* 检查前端多语言 key 完整性
*
* 扫描范围:frontend 下所有 .ts / .tsx / .js / .jsx(排除 node_modules、dist、build、*.d.ts
*
* 支持的检查:
* 1. 代码引用但 JSON 没有:某 ts/js 引用了 t("common.test"),但任意语言 common.json 里没有该 key → 报缺失
* 2. 语言间不一致:zh-CN/zh-TW 的 JSON 有 common.xxx,但 en 的没有 → 报不一致(某些语言有、某些没有)
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { glob } from 'glob';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
interface CheckResult {
usedKeys: Set<string>;
localeKeys: {
'zh-CN': Set<string>;
'zh-TW': Set<string>;
'en': Set<string>;
};
missingKeys: {
'zh-CN': string[];
'zh-TW': string[];
'en': string[];
};
inconsistentKeys: Array<{
key: string;
existsIn: string[];
missingIn: string[];
}>;
}
const LOCALES = ['zh-CN', 'zh-TW', 'en'] as const;
type Locale = typeof LOCALES[number];
// 颜色输出(如果支持)
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
function log(message: string, color: keyof typeof colors = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
/**
* 从代码文件中提取所有 t() 调用中的 key
*/
function extractKeysFromCode(frontendDir: string): Set<string> {
const keys = new Set<string>();
// 匹配 t('key') 或 t("key") 或 t(`key`)
const tPattern = /t\(['"`]([^'"`]+)['"`]\)/g;
// 扫描所有 TS/JS 文件(含 .ts .tsx .js .jsx),排除 node_modules、dist、build、*.d.ts
const files = glob.sync('**/*.{ts,tsx,js,jsx}', {
cwd: frontendDir,
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.d.ts'],
absolute: true,
});
for (const file of files) {
try {
const content = fs.readFileSync(file, 'utf-8');
let match;
while ((match = tPattern.exec(content)) !== null) {
const key = match[1];
// 过滤掉模板字符串中的变量(如 t(`key.${variable}`)
if (!key.includes('${') && !key.includes('${')) {
keys.add(key);
}
}
} catch (error) {
console.error(`Error reading file ${file}:`, error);
}
}
return keys;
}
/**
* 加载语言文件
*/
function loadLocaleFile(localeDir: string, locale: Locale): Record<string, any> {
const filePath = path.join(localeDir, locale, 'common.json');
if (!fs.existsSync(filePath)) {
log(`⚠️ 语言文件不存在: ${filePath}`, 'yellow');
return {};
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
} catch (error) {
log(`❌ 解析语言文件失败 ${filePath}: ${error}`, 'red');
return {};
}
}
/**
* 展平嵌套对象为点分隔的 key 路径
*/
function flattenKeys(obj: Record<string, any>, prefix = ''): Set<string> {
const keys = new Set<string>();
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// 递归处理嵌套对象
const nestedKeys = flattenKeys(value, fullKey);
nestedKeys.forEach(k => keys.add(k));
} else {
// 叶子节点
keys.add(fullKey);
}
}
return keys;
}
/**
* 检查 key 完整性
*/
function checkKeys(usedKeys: Set<string>, localeKeys: Record<Locale, Set<string>>): CheckResult {
const result: CheckResult = {
usedKeys,
localeKeys,
missingKeys: {
'zh-CN': [],
'zh-TW': [],
'en': [],
},
inconsistentKeys: [],
};
// 检查每个语言文件中缺失的 key
for (const locale of LOCALES) {
for (const key of usedKeys) {
if (!localeKeys[locale].has(key)) {
result.missingKeys[locale].push(key);
}
}
}
// 检查不一致的 key(某些语言有,某些没有)
const allLocaleKeys = new Set<string>();
LOCALES.forEach(locale => {
localeKeys[locale].forEach(key => allLocaleKeys.add(key));
});
for (const key of allLocaleKeys) {
const existsIn: Locale[] = [];
const missingIn: Locale[] = [];
for (const locale of LOCALES) {
if (localeKeys[locale].has(key)) {
existsIn.push(locale);
} else {
missingIn.push(locale);
}
}
// 如果某些语言有,某些没有,则不一致
if (existsIn.length > 0 && existsIn.length < LOCALES.length) {
result.inconsistentKeys.push({
key,
existsIn,
missingIn,
});
}
}
return result;
}
/**
* 打印检查报告
*/
function printReport(result: CheckResult): void {
log('\n=== 多语言 Key 检查报告 ===\n', 'cyan');
// 统计信息
log('📊 统计信息:', 'blue');
log(` - 代码中使用的 key 数量: ${result.usedKeys.size}`);
log(` - zh-CN 语言文件 key 数量: ${result.localeKeys['zh-CN'].size}`);
log(` - zh-TW 语言文件 key 数量: ${result.localeKeys['zh-TW'].size}`);
log(` - en 语言文件 key 数量: ${result.localeKeys['en'].size}`);
log('');
// 缺失的 key
let hasMissing = false;
for (const locale of LOCALES) {
if (result.missingKeys[locale].length > 0) {
hasMissing = true;
log(`❌ 缺失的 Key (代码中使用但 ${locale} 语言文件中不存在):`, 'red');
result.missingKeys[locale].forEach(key => {
log(` - ${key}`, 'red');
});
log('');
}
}
// 不一致的 key
if (result.inconsistentKeys.length > 0) {
log('⚠️ 不一致的 Key (某些语言文件有,某些没有):', 'yellow');
result.inconsistentKeys.forEach(({ key, existsIn, missingIn }) => {
log(` - ${key}`, 'yellow');
log(` 存在于: ${existsIn.join(', ')}`, 'yellow');
log(` 缺失于: ${missingIn.join(', ')}`, 'yellow');
});
log('');
}
// 总结
const hasErrors = hasMissing || result.inconsistentKeys.length > 0;
if (hasErrors) {
log('❌ 检查失败:发现缺失或不一致的 key', 'red');
} else {
log('✅ 检查通过:所有 key 都完整且一致', 'green');
}
}
/**
* 主函数
*/
function main(): void {
// 脚本位于 .cursor/skills/frontend/check-i18n-keys/scripts/,向上到项目根
const scriptDir = __dirname;
const skillDir = path.resolve(scriptDir, '..');
const frontendSkillDir = path.resolve(skillDir, '..');
const repoRoot = path.resolve(frontendSkillDir, '../../..');
const frontendDir = path.join(repoRoot, 'frontend');
const localesDir = path.join(frontendDir, 'src', 'locales');
// 检查目录是否存在
if (!fs.existsSync(frontendDir)) {
log(`❌ 前端目录不存在: ${frontendDir}`, 'red');
process.exit(1);
}
if (!fs.existsSync(localesDir)) {
log(`❌ 语言文件目录不存在: ${localesDir}`, 'red');
process.exit(1);
}
log('🔍 开始检查多语言 key...\n', 'cyan');
// 1. 提取代码中使用的 key
log('📝 扫描代码文件...', 'blue');
const usedKeys = extractKeysFromCode(frontendDir);
log(` 找到 ${usedKeys.size} 个使用的 key\n`, 'green');
// 2. 加载语言文件
log('📚 加载语言文件...', 'blue');
const localeKeys: Record<Locale, Set<string>> = {
'zh-CN': new Set(),
'zh-TW': new Set(),
'en': new Set(),
};
for (const locale of LOCALES) {
const localeObj = loadLocaleFile(localesDir, locale);
localeKeys[locale] = flattenKeys(localeObj);
log(` ${locale}: ${localeKeys[locale].size} 个 key`, 'green');
}
log('');
// 3. 检查
log('🔎 检查 key 完整性...', 'blue');
const result = checkKeys(usedKeys, localeKeys);
// 4. 输出报告
printReport(result);
// 5. 退出码
const hasErrors =
result.missingKeys['zh-CN'].length > 0 ||
result.missingKeys['zh-TW'].length > 0 ||
result.missingKeys['en'].length > 0 ||
result.inconsistentKeys.length > 0;
process.exit(hasErrors ? 1 : 0);
}
// 运行
main();
@@ -1,680 +0,0 @@
{
"name": "cursor-skill-frontend-scripts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-skill-frontend-scripts",
"version": "1.0.0",
"dependencies": {
"glob": "^13.0.1"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@types/node": {
"version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz",
"integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.1.2",
"minipass": "^7.1.2",
"path-scurry": "^2.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/lru-cache": {
"version": "11.2.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/minimatch": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.1"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
@@ -1,16 +0,0 @@
{
"name": "cursor-skill-frontend-scripts",
"version": "1.0.0",
"type": "module",
"scripts": {
"check-i18n": "tsx check-i18n-keys.ts"
},
"dependencies": {
"glob": "^13.0.1"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"downlevelIteration": true,
"types": ["node"]
},
"include": ["*.ts"],
"exclude": ["node_modules", "dist"]
}
+2 -2
View File
@@ -14,11 +14,11 @@
.DS_Store
# 构建产物
# 注意:frontend/dist 和 backend/build/libs 在使用 BUILD_IN_DOCKER=false 时是必需的
# 所以不能忽略它们。在 BUILD_IN_DOCKER=true 时,它们会被 Docker 内部编译覆盖
backend/build/
backend/.gradle/
backend/out/
backend/bin/
frontend/dist/
frontend/node_modules/
frontend/.vite/
frontend/.cache/
+21 -246
View File
@@ -4,104 +4,39 @@ on:
release:
types:
- published # 当通过 GitHub Releases 页面创建 release 时触发
workflow_dispatch:
inputs:
build_type:
description: '构建类型'
required: true
type: choice
options:
- package-only # 只打包产物
- package-and-docker # 打包产物 + Docker 镜像
default: 'package-and-docker'
version:
description: '版本号(例如: v1.0.0'
required: false
type: string
tag_name:
description: 'Git Tag 名称(留空则使用 version'
required: false
type: string
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: write # 需要写权限以上传 Assets
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
ref: ${{ github.event.release.tag_name }} # 使用 release 对应的 tag
- name: Determine build type
id: build_config
run: |
# 确定构建类型
if [ "${{ github.event_name }}" = "release" ]; then
# Release 事件:默认只打包产物(不构建 Docker)
BUILD_TYPE="package-only"
echo "📦 Release 事件:将只打包产物(不构建 Docker)"
else
# workflow_dispatch 事件:使用用户输入
BUILD_TYPE="${{ github.event.inputs.build_type }}"
echo "🔧 手动触发:构建类型 = ${BUILD_TYPE}"
fi
echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_OUTPUT
- name: Extract version and check if pre-release
- name: Extract version from release
id: extract_version
run: |
# 从不同事件源提取版本号
if [ "${{ github.event_name }}" = "release" ]; then
# Release 事件:从 release tag 中提取
TAG_NAME="${{ github.event.release.tag_name }}"
IS_PRERELEASE="${{ github.event.release.prerelease }}"
else
# workflow_dispatch 事件:从输入参数中提取
TAG_NAME="${{ github.event.inputs.tag_name }}"
if [ -z "$TAG_NAME" ]; then
TAG_NAME="${{ github.event.inputs.version }}"
fi
# 如果仍然为空,尝试从 git ref 中提取
if [ -z "$TAG_NAME" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
# 不是 tag,尝试从分支名或 commit SHA 获取
TAG_NAME=${GITHUB_REF#refs/heads/}
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
TAG_NAME="dev-$(date +%Y%m%d-%H%M%S)"
echo "⚠️ 未指定版本号,使用临时版本: $TAG_NAME"
fi
fi
fi
IS_PRERELEASE="false"
# 从 release tag 中提取版本号(例如 v1.0.0 -> 1.0.0
TAG_NAME="${{ github.event.release.tag_name }}"
if [ -z "$TAG_NAME" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
fi
# 验证版本号格式:v数字.数字.数字[-后缀](例如 v1.0.0, v2.10.102, v1.0.0-beta
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] && [[ ! "$TAG_NAME" =~ ^dev- ]]; then
echo "⚠️ 警告: 版本号格式不符合标准,但仍将继续构建"
echo " 当前版本号: $TAG_NAME"
echo " 标准格式应为: v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "错误: 版本号格式不正确,应为 v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
exit 1
fi
VERSION=${TAG_NAME#v} # 移除 v 前缀(如果存在)
VERSION=${TAG_NAME#v} # 移除 v 前缀
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
echo "IS_PRERELEASE=$IS_PRERELEASE" >> $GITHUB_OUTPUT
if [ "$IS_PRERELEASE" = "true" ]; then
echo "📋 这是 Pre-release: $TAG_NAME"
else
echo "📦 这是正式版本: $TAG_NAME"
fi
echo "Extracted version: $VERSION"
echo "Full tag: $TAG_NAME"
- name: Send Telegram notification (build started)
if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
@@ -113,15 +48,12 @@ jobs:
fi
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_URL="${{ github.event.release.html_url }}"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
MESSAGE="🔨 <b>Release 构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
MESSAGE="🔨 <b>构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"
fi
# 构建消息内容(仅包含关键信息)
MESSAGE="🔨 <b>Docker 镜像构建中</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
@@ -147,155 +79,19 @@ jobs:
exit 0
fi
# ============ 编译前后端产物 ============
- name: Setup JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build Backend JAR
run: |
cd backend
chmod +x gradlew
./gradlew bootJar --no-daemon
echo "✅ 后端构建完成"
ls -lh build/libs/*.jar
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Build Frontend
env:
VERSION: ${{ steps.extract_version.outputs.VERSION }}
GIT_TAG: ${{ steps.extract_version.outputs.TAG }}
GITHUB_REPO_URL: https://github.com/WrBug/PolyHermes
run: |
cd frontend
npm ci
npm run build
echo "✅ 前端构建完成"
echo "📦 版本信息: VERSION=${{ steps.extract_version.outputs.VERSION }}, GIT_TAG=${{ steps.extract_version.outputs.TAG }}"
du -sh dist/
# ============ 打包更新包 ============
- name: Create Update Package
run: |
echo "📦 开始打包更新包..."
# 创建目录结构
mkdir -p update-package/backend
mkdir -p update-package/frontend
# 复制后端 JAR
cp backend/build/libs/*.jar update-package/backend/polyhermes.jar
echo "✅ 后端 JAR 已复制"
# 复制前端产物
cp -r frontend/dist/* update-package/frontend/
echo "✅ 前端文件已复制"
# 创建版本信息文件
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_NOTES=$(echo '${{ github.event.release.body }}' | jq -Rs .)
else
RELEASE_NOTES="\"手动构建 - workflow_dispatch\""
fi
cat > update-package/version.json <<EOF
{
"version": "${{ steps.extract_version.outputs.VERSION }}",
"tag": "${{ steps.extract_version.outputs.TAG }}",
"buildTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"releaseNotes": ${RELEASE_NOTES}
}
EOF
echo "✅ 版本信息已创建"
# 打包成 tar.gz
cd update-package
tar -czf ../polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz .
cd ..
echo "✅ 打包完成: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz"
ls -lh polyhermes-*.tar.gz
- name: Calculate Checksum
id: checksum
run: |
FILE="polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz"
CHECKSUM=$(sha256sum "$FILE" | awk '{print $1}')
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_OUTPUT
echo "✅ SHA256: $CHECKSUM"
echo "$CHECKSUM $FILE" > checksums.txt
- name: Upload Update Package to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ./polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
asset_name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
asset_content_type: application/gzip
- name: Upload Checksums to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ./checksums.txt
asset_name: checksums.txt
asset_content_type: text/plain
- name: Upload Update Package as Artifact
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update
path: |
polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
checksums.txt
retention-days: 30
- name: Set up Docker Buildx
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/setup-buildx-action@v3
with:
# 启用多架构构建支持
platforms: linux/amd64,linux/arm64
- name: Log in to Docker Hub
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Prepare Docker build context
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
run: |
echo "📦 准备 Docker 构建上下文..."
# 确保构建产物存在且可访问
if [ ! -d "frontend/dist" ]; then
echo "❌ 错误:frontend/dist 不存在"
exit 1
fi
if [ ! -d "backend/build/libs" ] || [ -z "$(ls -A backend/build/libs/*.jar 2>/dev/null)" ]; then
echo "❌ 错误:backend/build/libs/*.jar 不存在"
exit 1
fi
echo "✅ 构建产物已准备好"
ls -lh frontend/dist/ | head -5
ls -lh backend/build/libs/*.jar
- name: Build and push Docker image
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/build-push-action@v5
with:
context: .
@@ -305,23 +101,15 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: |
wrbug/polyhermes:${{ steps.extract_version.outputs.TAG }}
${{ steps.extract_version.outputs.IS_PRERELEASE == 'false' && 'wrbug/polyhermes:latest' || '' }}
wrbug/polyhermes:latest
build-args: |
BUILD_IN_DOCKER=false
VERSION=${{ steps.extract_version.outputs.VERSION }}
GIT_TAG=${{ steps.extract_version.outputs.TAG }}
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
cache-from: type=registry,ref=wrbug/polyhermes:latest
cache-to: type=inline
- name: Skip Docker build notice
if: steps.build_config.outputs.BUILD_TYPE == 'package-only'
run: |
echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only"
echo "✅ 仅打包产物已完成"
- name: Send Telegram notification
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
@@ -335,26 +123,13 @@ jobs:
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}"
RELEASE_NAME="${{ github.event.release.name }}"
RELEASE_URL="${{ github.event.release.html_url }}"
REPO_NAME="${{ github.repository }}"
# 构建消息内容(仅包含关键信息)
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>Release 构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>Release 打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
fi
MESSAGE="✅ <b>Docker 镜像构建成功</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
+103
View File
@@ -0,0 +1,103 @@
name: Telegram Notification on PR Merge
on:
pull_request:
types:
- closed # 当 PR 被关闭(合并或关闭)时触发
jobs:
notify:
runs-on: ubuntu-latest
# 只在 PR 被合并到 main 分支时执行
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get PR details
id: pr_details
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
REPO="${{ github.repository }}"
# 获取 PR 详细信息
PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}")
# 获取 PR 变更的文件列表
FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files")
# 提取 PR 描述(body),保留换行,限制长度
PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""')
if [ ${#PR_BODY} -gt 500 ]; then
PR_BODY="${PR_BODY:0:500}..."
fi
# 保存到输出变量(使用 base64 编码避免特殊字符问题)
echo "pr_body<<EOF" >> $GITHUB_OUTPUT
echo "$PR_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Send Telegram notification
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查 PR 是否被合并(而不是仅关闭)
PR_MERGED="${{ github.event.pull_request.merged }}"
if [ "$PR_MERGED" != "true" ]; then
echo "ℹ️ PR 仅关闭,未合并,跳过通知"
exit 0
fi
# 检查必要的环境变量
# 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取 PR 基本信息
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_TITLE="${{ github.event.pull_request.title }}"
PR_URL="${{ github.event.pull_request.html_url }}"
PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
# 获取 PR 详细信息
PR_BODY="${{ steps.pr_details.outputs.pr_body }}"
# 转义 PR 标题中的 HTML 特殊字符
PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&amp;/g' | sed 's/</\&lt;/g' | sed 's/>/\&gt;/g')
# 构建消息内容(仅包含关键信息)
MESSAGE="🚀 <b>main 分支代码更新</b>"$'\n'$'\n'"📝 <b>PR #${PR_NUMBER}:</b> ${PR_TITLE_ESCAPED}"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
exit 1
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
exit 1
fi
+2 -4
View File
@@ -17,16 +17,14 @@ backend/out/
backend/*.log
backend/gradle-app.setting
backend/.gradle
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
# backend/gradle/wrapper/gradle-wrapper.jar
polyhub/
backend/gradle-wrapper.jar
# Kotlin
*.kt.bak
*.class
# Java
*.jar
!backend/gradle/wrapper/gradle-wrapper.jar # Gradle Wrapper JAR 应该被提交
*.war
*.ear
*.class
+31 -88
View File
@@ -1,12 +1,6 @@
# 多阶段构建:前后端一体化部署(支持混合编译)
# 构建参数:控制是否在 Docker 内编译
# - BUILD_IN_DOCKER=true (默认): Docker 内部编译(本地开发)
# - BUILD_IN_DOCKER=false: 使用外部产物(GitHub Actions
ARG BUILD_IN_DOCKER=true
# ==================== 阶段1:构建前端 ====================
# 多阶段构建:前后端一体化部署
# 阶段1:构建前端
FROM node:18-alpine AS frontend-build
ARG BUILD_IN_DOCKER
WORKDIR /app/frontend
@@ -19,37 +13,19 @@ ARG GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
ENV VERSION=${VERSION}
ENV GIT_TAG=${GIT_TAG}
ENV GITHUB_REPO_URL=${GITHUB_REPO_URL}
# 复制前端文件(先复制 package.json 以利用 Docker 缓存)
# 复制前端文件
COPY frontend/package*.json ./
RUN npm ci
# 条件:仅在 Docker 内部编译时安装依赖
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
npm ci; \
fi
# 复制所有前端源文件
COPY frontend/ ./
# 条件:仅在 Docker 内部编译时执行构建
# 如果 BUILD_IN_DOCKER=false,需要确保构建上下文中存在 frontend/dist
# 注意:COPY frontend/ ./ 已经复制了整个 frontend 目录(包括 dist,如果存在)
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
echo "🔨 Docker 内部编译前端..."; \
npm run build; \
else \
echo "⏭️ 使用外部产物..."; \
if [ ! -d "dist" ] || [ -z "$(ls -A dist 2>/dev/null)" ]; then \
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 frontend/dist"; \
echo " 请先执行: cd frontend && npm install && npm run build"; \
exit 1; \
else \
echo "✅ 找到外部构建的前端产物"; \
fi; \
fi
# 构建前端(使用相对路径,通过 Nginx 代理)
# 版本号会通过环境变量注入到构建产物中
RUN npm run build
# ==================== 阶段2:构建后端 ====================
# 阶段2:构建后端
FROM gradle:8.5-jdk17 AS backend-build
ARG BUILD_IN_DOCKER
WORKDIR /app/backend
@@ -57,93 +33,60 @@ WORKDIR /app/backend
COPY backend/build.gradle.kts backend/settings.gradle.kts ./
COPY backend/gradle ./gradle
# 条件:仅在 Docker 内部编译时下载依赖
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
gradle dependencies --no-daemon || true; \
fi
# 下载依赖(利用 Docker 缓存)
RUN gradle dependencies --no-daemon || true
# 复制源代码
COPY backend/src ./src
# 尝试复制外部构建的 JAR(如果存在)
# 注意:COPY 指令如果源不存在会失败
# GitHub Actions 使用 BUILD_IN_DOCKER=false,会先构建产物,所以 backend/build 应该存在
# 本地开发使用 BUILD_IN_DOCKER=true,会在 Docker 内编译,所以 backend/build 可能不存在
# 解决方案:先复制整个 backend 目录(包括 build,如果存在),然后只使用需要的部分
# 使用 .dockerignore 确保不会复制不需要的文件(如 .gradle、out、bin 等)
COPY backend/build ./build-external
# 构建应用
RUN gradle bootJar --no-daemon
# 处理外部构建的 JAR(如果存在)
RUN if [ -d "build-external/libs" ] && [ -n "$(ls -A build-external/libs/*.jar 2>/dev/null)" ]; then \
echo "📦 找到外部构建的后端产物,复制到 build/libs..."; \
mkdir -p build/libs; \
cp build-external/libs/*.jar build/libs/; \
rm -rf build-external; \
else \
echo "⏭️ 未找到外部构建的 JAR,将在 Docker 内编译"; \
rm -rf build-external; \
mkdir -p build/libs; \
fi
# 条件:仅在 Docker 内部编译时执行构建(会覆盖外部产物)
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
echo "🔨 Docker 内部编译后端..."; \
gradle bootJar --no-daemon; \
else \
echo "⏭️ 使用外部产物"; \
if [ -z "$(ls -A build/libs/*.jar 2>/dev/null)" ]; then \
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 backend/build/libs/*.jar"; \
echo " 请先执行: cd backend && ./gradlew bootJar"; \
exit 1; \
else \
echo "✅ 使用外部构建的后端产物"; \
fi; \
fi
# ==================== 阶段3:运行环境 ====================
# 阶段3:运行环境
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
# 安装 Nginx、Python 和必要的工具
# 安装 Nginx 和必要的工具
RUN apt-get update && \
apt-get install -y nginx curl tzdata jq python3 python3-flask python3-requests && \
apt-get install -y nginx curl && \
rm -rf /var/lib/apt/lists/* && \
rm -rf /etc/nginx/sites-enabled/default
# 从构建阶段复制文件
# 当 BUILD_IN_DOCKER=false 时,构建阶段已经复制了外部产物
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
COPY --from=backend-build /app/backend/build/libs/*.jar app.jar
# 复制 Nginx 配置
COPY docker/nginx.conf /etc/nginx/nginx.conf
# 创建更新服务相关目录和脚本
RUN mkdir -p /app/updates /app/backups /var/log/polyhermes
COPY docker/update-service.py /app/update-service.py
# 创建启动脚本
COPY docker/start.sh /app/start.sh
RUN chmod +x /app/start.sh
# 记录初始版本(从构建参数
ARG VERSION=dev
ARG GIT_TAG=dev
RUN echo "{\"version\":\"${VERSION}\",\"tag\":\"${GIT_TAG}\",\"buildTime\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > /app/version.json
# 创建非 root 用户
# 创建非 root 用户(用于运行后端应用
RUN useradd -m -u 1000 appuser
# 设置目录权限
# 设置目录权限Nginx 以 root 运行,后端应用以 appuser 运行)
RUN mkdir -p /var/log/nginx /var/lib/nginx /var/cache/nginx /var/run && \
chown -R appuser:appuser /app && \
chown -R root:root /usr/share/nginx/html /var/log/nginx /var/lib/nginx /var/cache/nginx /etc/nginx /var/run
chown -R root:root /usr/share/nginx/html && \
chown -R root:root /var/log/nginx && \
chown -R root:root /var/lib/nginx && \
chown -R root:root /var/cache/nginx && \
chown -R root:root /etc/nginx && \
chown -R root:root /var/run
# 保持 root 用户(Nginx 需要 root 权限绑定 80 端口)
# USER appuser
# 暴露端口
EXPOSE 80
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost/api/system/health || exit 1
CMD curl -f http://localhost/api/health || exit 1
# 启动服务
# 启动服务(同时启动 Nginx 和后端)
ENTRYPOINT ["/app/start.sh"]
+1028
View File
File diff suppressed because it is too large Load Diff
-27
View File
@@ -2,7 +2,6 @@
[![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/PolyHermes)
[![Twitter](https://img.shields.io/badge/Twitter-@polyhermes-blue?logo=twitter)](https://x.com/polyhermes)
[![Docker](https://img.shields.io/docker/v/wrbug/polyhermes?label=Docker&logo=docker)](https://hub.docker.com/r/wrbug/polyhermes)
> 🌐 **Language**: [English](README_EN.md) | 中文
@@ -116,7 +115,6 @@
- **API 健康检查**:实时监控 Polymarket API 的健康状态
- **用户管理**:管理系统用户,支持添加、编辑、删除用户
- **公告管理**:查看系统公告和更新信息
- **动态更新**:支持在线更新系统版本,无需重新部署容器
### 🚀 技术特性
@@ -165,29 +163,6 @@
**部署步骤**
#### ⚡ 一键安装(最快,推荐新用户)
**使用 curl(推荐):**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
```
**使用 wget**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
```
这个命令会自动:
- 📁 创建专用工作目录 `~/polyhermes`
- ✅ 自动检查 Docker 环境
- ⚙️ 交互式配置所有参数(支持回车使用默认值)
- 🔐 自动生成安全的随机密钥
- 🚀 自动下载最新镜像并部署
---
#### 📋 其他部署方式
1. **使用 Docker Hub 镜像(推荐,生产环境首选)**
**方式 1:独立部署(无需 clone 代码,推荐)**
@@ -421,7 +396,6 @@ cd frontend
- [开发文档](docs/zh/DEVELOPMENT.md) - 开发指南
- [跟单系统需求文档](docs/zh/copy-trading-requirements.md) - 后端 API 接口文档
- [前端需求文档](docs/zh/copy-trading-frontend-requirements.md) - 前端功能文档
- [动态更新文档](docs/zh/DYNAMIC_UPDATE.md) - 动态更新功能说明
### 🤝 贡献指南
@@ -458,7 +432,6 @@ cd frontend
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
- [Twitter](https://x.com/polyhermes)
- [Telegram 群组](https://t.me/polyhermes)
- [Polymarket 官网](https://polymarket.com)
- [Polymarket API 文档](https://docs.polymarket.com)
-25
View File
@@ -2,7 +2,6 @@
[![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/PolyHermes)
[![Twitter](https://img.shields.io/badge/Twitter-@polyhermes-blue?logo=twitter)](https://x.com/polyhermes)
[![Docker](https://img.shields.io/docker/v/wrbug/polyhermes?label=Docker&logo=docker)](https://hub.docker.com/r/wrbug/polyhermes)
> 🌐 **Language**: English | [中文](README.md)
@@ -164,29 +163,6 @@ Deploy both frontend and backend together in a single Docker container, using Ng
**Deployment Steps**:
#### ⚡ One-Liner Installation (Fastest, Recommended for New Users)
**Using curl (Recommended):**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
```
**Using wget:**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
```
This command will automatically:
- 📁 Create dedicated working directory `~/polyhermes`
- ✅ Automatically check Docker environment
- ⚙️ Interactive configuration for all parameters (supports pressing Enter for defaults)
- 🔐 Automatically generate secure random keys
- 🚀 Automatically download latest images and deploy
---
#### 📋 Alternative Deployment Methods
1. **Using Docker Hub Images (Recommended, Production First Choice)**
**Method 1: Standalone Deployment (No code clone required, Recommended)**
@@ -456,7 +432,6 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
- [Twitter](https://x.com/polyhermes)
- [Telegram Group](https://t.me/polyhermes)
- [Polymarket Official Website](https://polymarket.com)
- [Polymarket API Documentation](https://docs.polymarket.com)
+278
View File
@@ -0,0 +1,278 @@
# v1.1.2
## 🚀 主要功能
### 🐛 修复内存泄漏问题
- 修复 Retrofit/OkHttpClient 实例重复创建导致的内存泄漏问题
- 为不需要认证的 API 创建共享的 OkHttpClient 实例(Gamma API、Data API、GitHub API 等)
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
- RPC API 按 RPC URL 缓存,Builder Relayer API 按 relayerUrl 缓存
- 添加 `@PreDestroy` 方法清理缓存,确保资源正确释放
- **效果**:内存占用从运行几小时后从 400MB 涨到 1GB+ 变为保持稳定,大幅减少内存占用
### 📊 市场价格服务优化
- 移除降级查询逻辑,仅保留链上 RPC 查询和 CLOB 订单簿查询
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
- 如果所有数据源都失败,抛出明确的异常信息
- 价格截位到 4 位小数(向下截断,不四舍五入)
- 简化代码逻辑,提高查询效率和准确性
### 🔧 代码架构优化
- 统一 Gson 使用,改为依赖注入方式
-`GsonConfig` 中统一配置 Gson Beanlenient 模式)
- 所有 Service 类通过构造函数注入 Gson 实例
- 移除所有 `GsonConverterFactory.create()` 无参调用,统一使用注入的 Gson
- 提高代码一致性和可维护性
### 🗑️ 功能清理
- 移除下单失败存储数据库的功能
- 删除 `FailedTrade` 实体类和 `FailedTradeRepository`
-`CopyOrderTrackingService` 中移除失败交易存储逻辑
- 创建 Flyway migration V16 删除 `failed_trade`
- 下单失败时仅记录日志,不再存储到数据库,简化数据模型
### 🚀 部署优化
- 自动使用当前分支名作为 Docker 版本号
- 分支名中的 `/` 自动替换为 `-`Docker tag 不支持 `/
- `docker-compose.yml` 启用 build args,从环境变量读取版本号
- 前端页面将显示当前分支名作为版本号
- 如果没有 Git 仓库或获取失败,使用默认值 `dev`
## 🐛 Bug 修复
### 修复 Flyway Migration 问题
- 恢复 V1 migration 文件,避免 checksum 不匹配
- 保持 `V1__init_database.sql` 的原有内容不变
- `failed_trade` 表的删除通过 V16 migration 处理
- 确保已有数据库的 migration checksum 保持一致
### 修复前端编译错误
- 修复 `PositionList.tsx` 中引用不存在的 `bestBid` 属性导致的编译错误
- 使用 `currentPrice` 替代 `bestBid`,确保前端代码可以正常编译
## 📚 文档更新
- 新增智能资金分析文档(`docs/zh/smart-money-analysis.md`
- 详细说明智能资金分析功能的使用方法和策略
## 🔧 技术改进
- 优化 `RetrofitFactory`,实现客户端实例缓存和复用
- 优化 `CopyOrderTrackingService`,移除失败交易相关逻辑
- 优化 `OrderStatusUpdateService`,增强订单状态更新功能
- 优化 `TelegramNotificationService`,改进通知逻辑
- 优化 `PositionCheckService`,简化代码结构
- 优化 `PolymarketClobService`,改进 API 调用逻辑
## 📦 数据库变更
- 删除 `failed_trade` 表(Migration: V16
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.2
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.1...v1.1.2
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## 📊 统计信息
- **文件变更**: 29 个文件
- **代码变更**: +1597 行 / -678 行
- **主要提交**: 8 个提交
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.2
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
---
# v1.1.1
## 🚀 主要功能
### 🔗 链上 WebSocket 监听优化
- 创建 `UnifiedOnChainWsService` 统一管理 WebSocket 连接,所有服务共享同一个连接
- 创建 `OnChainWsUtils` 工具类,提取公共的链上 WebSocket 相关功能
- 创建 `AccountOnChainMonitorService` 监听账户链上卖出和赎回事件
- 优化 `OnChainWsService`,复用公共代码,减少代码重复
- 支持通过链上 WebSocket 实时监听账户的卖出和赎回交易,自动更新订单状态
### 📊 市场状态查询优化
- 优化市场结算状态查询,优先使用链上查询 `ConditionalTokens.getCondition`
- 如果链上查询失败,自动降级到 Gamma API 查询
- 提供更实时和准确的市场结算结果
### 🔕 自动订单通知优化
- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_ 前缀)不再发送 Telegram 通知
- 优化 `OrderStatusUpdateService`,跳过自动生成订单的通知处理
- 减少不必要的通知,提升用户体验
## 🐛 Bug 修复
### 修复移动端 API 健康页面缺少数据显示
- 移动端添加 URL 地址显示
- 移动端添加状态文本显示(正常/异常/未配置)
- 移动端添加消息/状态信息显示
- 移动端和桌面端显示信息保持一致
## 🔧 功能优化
### 优化 Telegram 推送消息格式
- 添加价格和数量截位处理:
* 价格保留最多4位小数(截断,不四舍五入)
* 数量保留最多2位小数(截断,不四舍五入)
- 优化账户信息显示格式:
* 有账户名和钱包地址时显示:账户名(0x123...123)
* 只有账户名时显示账户名
* 只有钱包地址时显示脱敏后的地址
* 都没有时显示未知账户
### 配置优化
- 移除 `polygon.rpc.url` 配置,使用 RpcNodeService 统一管理 RPC 节点
- 删除无用的 `position.push.polling-interval``position.push.heartbeat-timeout` 配置项
- 修正日志配置中的包名(polyhermes -> polymarketbot
- 更新 `ApiHealthCheckService` 直接使用 `RpcNodeService.getHttpUrl()`
## 📚 文档更新
- 统一发布说明文件,使用 RELEASE.md 替代版本化文件(RELEASE_v1.0.1.md、RELEASE_v1.1.0.md
- 更新所有部署文档,移除 POLYGON_RPC_URL 相关说明
- 更新所有 Docker Compose 配置文件,移除 POLYGON_RPC_URL 环境变量
- 更新所有部署脚本,移除 POLYGON_RPC_URL 环境变量定义
## 🔧 技术改进
- 重构链上 WebSocket 服务,提取公共代码到 `OnChainWsUtils`
- 创建统一的 WebSocket 连接管理服务 `UnifiedOnChainWsService`
- 添加链上查询市场结算结果的功能(`BlockchainService.getCondition`
- 添加 ABI 编码/解码工具方法(`EthereumUtils.decodeConditionResult`
- 优化代码结构,减少代码重复,提高可维护性
---
# v1.1.0
## 🚀 主要功能
### 🔗 链上 WebSocket 实时监听
- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易
- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件
- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个
- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
- 跟单配置生效/失效时及时更新 WebSocket 订阅
- 使用 Gson 替换所有 JSON 解析,提高解析稳定性
- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复
### 📊 RPC 节点管理
- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点
- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略
- 前端添加启用/禁用开关,支持实时切换节点状态
- 健康检查只检查启用的节点,提高检查效率
- 节点选择时自动过滤禁用的节点
### 💰 卖出订单价格轮询更新
- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新
- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次:
- 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
- 清理已删除账户的订单记录
- 支持加权平均价格计算,处理部分成交的订单
- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新
- 下单完成后不再立即查询价格,直接保存,等待定时任务更新
## 🐛 Bug 修复
### 修复跟单卖出订单的 API 凭证解密问题
- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致
- 确保卖出订单能够正常使用 API 凭证进行认证
### 修复 SELL 订单精度问题
- 修复 SELL 订单的 `makerAmount``takerAmount` 精度问题:
- `makerAmount` (shares) 最多 2 位小数(符合 API 要求)
- `takerAmount` (USDC) 最多 4 位小数(符合 API 要求)
- 确保订单能够正常提交到 Polymarket API
## 📚 文档更新
- 添加 Docker 版本更新说明(中英文)
- 添加链上 WebSocket 监听策略文档
- 添加跟单逻辑总结文档
- 更新部署文档,包含详细的版本更新步骤
## 🔧 技术改进
- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性
- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构
- 优化 WebSocket 连接管理,减少不必要的连接
- 添加线程安全机制,使用 Kotlin Coroutines Mutex
- 启用 Spring 定时任务功能(`@EnableScheduling`
## 📦 数据库变更
- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.1
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.0...v1.1.1
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## 📊 统计信息
- **文件变更**: 32 个文件
- **代码变更**: +1872 行 / -1503 行
- **主要提交**: 7 个提交
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.1
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
-77
View File
@@ -1,77 +0,0 @@
## 版本信息
- **版本号**: v1.1.15
- **发布日期**: 2026-01-19
- **基础版本**: v1.1.14
## 改动摘要
本次版本优化了订单详情处理逻辑,提升了系统稳定性和开发体验。
## 主要改动
### 🐛 Bug 修复
#### 1. 优化订单详情为 null 时的处理逻辑
- **提交**: 7385eff
- **文件**: OrderStatusUpdateService.kt
- **问题**:
- 订单详情为 null 且已发送通知超过 60 秒时,订单被直接删除
- 导致已经正确处理并发送 TG 通知的订单被意外删除
- **修复**:
- 当订单详情为 null 且 notificationSent = true 超过 60 秒时,将订单状态改为 fully_matched
- fully_matched 状态的订单会被自动过滤,不再查询详情
- 避免已处理的订单被误删除
### 🧹 日志清理
#### 2. 清理 MarketPollingService 中多余的 debug 日志
- **提交**: d768da7, 07b4d65
- **文件**: MarketPollingService.kt
- **改进**:
- 删除多余的 debug 日志输出
- 减少冗余日志,提升日志可读性
- 优化性能(减少日志 I/O
### ✨ 新增功能
#### 3. 添加订单详情查询工具脚本
- **提交**: b658270
- **新增文件**:
- scripts/get-order-detail.js - 订单详情查询脚本
- scripts/package.json - 依赖配置文件
- **功能**:
- 快速查询 Polymarket 订单详情
- 支持自动创建 API Key
- 完善的错误处理和参数验证
- 详细的订单信息输出
## 文件变更统计
- **修改文件数**: 3
- **新增文件数**: 2
- **新增行数**: 192
- **删除行数**: 6
## 技术细节
### 订单状态管理优化
- 使用 fully_matched 状态标记已处理订单
- 通过数据库查询条件自动过滤,无需额外缓存
- 保持数据一致性和可追溯性
### 工具脚本特性
- 基于 Polymarket CLOB Client v5.2.1
- 支持 derive/create API Key 自动处理
- 完整的参数验证和错误提示
- 友好的命令行交互体验
## 升级建议
- 无需特殊操作,直接部署即可
- 建议验证订单处理逻辑是否正常工作
- 可以使用新增的工具脚本进行调试
## 完整提交列表
- 7385eff - 优化订单详情为null时的处理逻辑
- d768da7 - 清理 MarketPollingService 中多余的 debug 日志
- b658270 - 添加订单详情查询脚本
- 07b4d65 - 清理 MarketPollingService 调试日志
+1 -1
View File
@@ -130,7 +130,7 @@ export PROXY_PORT=8888
- 代理配置错误
**排查步骤**:
1. 检查 Polymarket RTDS WebSocket URL(现在使用代码常量 `PolymarketConstants.RTDS_WS_URL`
1. 检查 `polymarket.rtds.ws-url` 配置是否正确
2. 检查网络连接
3. 查看详细错误日志
-3
View File
@@ -68,9 +68,6 @@ 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")
-5
View File
@@ -9,7 +9,6 @@ services:
ports:
- "${SERVER_PORT:-8000}:8000"
environment:
- TZ=${TZ:-Asia/Shanghai}
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
- DB_USERNAME=${DB_USERNAME:-root}
@@ -17,8 +16,6 @@ services:
- SERVER_PORT=8000
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
volumes:
- /etc/localtime:/etc/localtime:ro
depends_on:
mysql:
condition: service_healthy
@@ -32,14 +29,12 @@ services:
ports:
- "${MYSQL_PORT:-3306}:3306"
environment:
- TZ=${TZ:-Asia/Shanghai}
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
- MYSQL_DATABASE=polyhermes
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
volumes:
- mysql-data:/var/lib/mysql
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
interval: 10s
-59
View File
@@ -1,59 +0,0 @@
#!/bin/bash
# 清理 Flyway V29 失败记录的脚本
echo "=== 清理 Flyway V29 失败记录 ==="
echo ""
echo "请确保 MySQL 正在运行,然后输入数据库密码"
echo ""
# 数据库配置
DB_HOST="localhost"
DB_PORT="3306"
DB_NAME="polymarket_bot"
DB_USER="root"
# 检查 MySQL 命令是否可用
if ! command -v mysql &> /dev/null; then
echo "❌ 错误: 未找到 mysql 命令"
echo ""
echo "请使用数据库客户端(如 Navicat、DataGrip 等)执行以下 SQL"
echo ""
echo "-- 1. 查看 Flyway 历史记录"
echo "SELECT version, description, installed_on, success "
echo "FROM flyway_schema_history "
echo "WHERE version >= 28"
echo "ORDER BY installed_rank;"
echo ""
echo "-- 2. 删除 V29 的失败记录"
echo "DELETE FROM flyway_schema_history WHERE version = '29';"
echo ""
exit 1
fi
# 执行清理
echo "正在连接数据库..."
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p "$DB_NAME" << 'EOF'
-- 查看当前状态
SELECT '=== 当前 Flyway 历史记录 ===' as '';
SELECT version, description, installed_on, success
FROM flyway_schema_history
WHERE version >= 28
ORDER BY installed_rank;
-- 删除 V29 失败记录
SELECT '=== 删除 V29 记录 ===' as '';
DELETE FROM flyway_schema_history WHERE version = '29';
-- 确认删除结果
SELECT CONCAT('已删除 ', ROW_COUNT(), ' 条记录') as result;
-- 再次查看状态
SELECT '=== 清理后的 Flyway 历史记录 ===' as '';
SELECT version, description, installed_on, success
FROM flyway_schema_history
WHERE version >= 28
ORDER BY installed_rank;
EOF
echo ""
echo "✅ 清理完成!现在可以重启应用了"
Binary file not shown.
@@ -41,17 +41,6 @@ interface BuilderRelayerApi {
@Query("address") address: String,
@Query("type") type: String
): Response<NoncePayload>
/**
* 获取 Relay PayloadPROXY 类型执行时使用)
* GET /relay-payload?address={address}&type=PROXY
* 参考: builder-relayer-client endpoints GET_RELAY_PAYLOAD
*/
@GET("/relay-payload")
suspend fun getRelayPayload(
@Query("address") address: String,
@Query("type") type: String
): Response<RelayPayload>
/**
* 获取交易状态
@@ -107,7 +96,6 @@ interface BuilderRelayerApi {
/**
* 签名参数
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
* Safe 使用 operation/safeTxnGas/baseGas 等,PROXY 使用 relayHub/relay/relayerFee 等
*/
data class SignatureParams(
@SerializedName("gasPrice")
@@ -126,19 +114,7 @@ interface BuilderRelayerApi {
val gasToken: String? = null,
@SerializedName("refundReceiver")
val refundReceiver: String? = null,
@SerializedName("relayerFee")
val relayerFee: String? = null,
@SerializedName("gasLimit")
val gasLimit: String? = null,
@SerializedName("relayHub")
val relayHub: String? = null,
@SerializedName("relay")
val relay: String? = null
val refundReceiver: String? = null
)
/**
@@ -166,17 +142,6 @@ interface BuilderRelayerApi {
@SerializedName("nonce")
val nonce: String
)
/**
* Relay PayloadPROXY 执行时获取 relay 地址与 nonce
* 参考: builder-relayer-client types RelayPayload
*/
data class RelayPayload(
@SerializedName("address")
val address: String,
@SerializedName("nonce")
val nonce: String
)
/**
* Relayer 交易详情
@@ -0,0 +1,102 @@
package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* NBA Stats API 接口
* Base URL: https://stats.nba.com/stats/
*
* 注意:NBA Stats API 需要设置正确的请求头:
* - User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
* - Referer: https://www.nba.com/
* - Accept: application/json
*/
interface NbaStatsApi {
/**
* 获取赛程和比分
* @param GameDate 比赛日期,格式:YYYY-MM-DD,不传则获取今天的比赛
* @param LeagueID 联盟ID,默认:00 (NBA)
* @param DayOffset 日期偏移,默认:0
* @return ScoreboardResponse
*/
@GET("Scoreboard")
suspend fun getScoreboard(
@Query("GameDate") gameDate: String? = null,
@Query("LeagueID") leagueId: String = "00",
@Query("DayOffset") dayOffset: Int = 0
): Response<ScoreboardResponse>
}
/**
* NBA Stats API Scoreboard 响应
*/
data class ScoreboardResponse(
val resultSets: List<ResultSet>
)
/**
* Result Set
*/
data class ResultSet(
val name: String,
val headers: List<String>,
val rowSet: List<List<Any?>>
)
/**
* Game Header (从 Scoreboard 的 resultSets[0] 获取)
* Headers: ["GAME_DATE_EST", "GAME_SEQUENCE", "GAME_ID", "GAME_STATUS_ID", "GAME_STATUS_TEXT",
* "GAMECODE", "HOME_TEAM_ID", "VISITOR_TEAM_ID", "SEASON", "LIVE_PERIOD",
* "LIVE_PC_TIME", "NATL_TV_BROADCASTER_ABBREV", "LIVE_PERIOD_TIME_BCAST", "WH_STATUS"]
*/
data class GameHeader(
val gameDateEst: String,
val gameSequence: Int,
val gameId: String,
val gameStatusId: Int,
val gameStatusText: String,
val gameCode: String,
val homeTeamId: Int,
val visitorTeamId: Int,
val season: String,
val livePeriod: Int?,
val livePcTime: String?,
val natlTvBroadcasterAbbrev: String?,
val livePeriodTimeBcast: String?,
val whStatus: Int?
)
/**
* Line Score (从 Scoreboard 的 resultSets[1] 获取)
* Headers: ["GAME_DATE_EST", "GAME_SEQUENCE", "GAME_ID", "TEAM_ID", "TEAM_ABBREVIATION",
* "TEAM_NAME", "PTS_QTR1", "PTS_QTR2", "PTS_QTR3", "PTS_QTR4", "PTS_OT1",
* "PTS_OT2", "PTS_OT3", "PTS_OT4", "PTS", "FG_PCT", "FT_PCT", "FG3_PCT",
* "AST", "REB", "TOV"]
*/
data class LineScore(
val gameDateEst: String,
val gameSequence: Int,
val gameId: String,
val teamId: Int,
val teamAbbreviation: String,
val teamName: String,
val ptsQtr1: Int?,
val ptsQtr2: Int?,
val ptsQtr3: Int?,
val ptsQtr4: Int?,
val ptsOt1: Int?,
val ptsOt2: Int?,
val ptsOt3: Int?,
val ptsOt4: Int?,
val pts: Int,
val fgPct: Double?,
val ftPct: Double?,
val fg3Pct: Double?,
val ast: Int?,
val reb: Int?,
val tov: Int?
)
@@ -149,19 +149,6 @@ interface PolymarketClobApi {
@GET("/auth/derive-api-key")
suspend fun deriveApiKey(): Response<ApiKeyResponse>
/**
* 获取费率
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
* 端点: GET /fee-rate
*
* @param tokenId Token ID
* @return 费率响应
*/
@GET("/fee-rate")
suspend fun getFeeRate(
@Query("token_id") tokenId: String
): Response<FeeRateResponse>
/**
* 获取服务器时间
* 端点: /time
@@ -370,18 +357,6 @@ data class ServerTimeResponse(
val timestamp: Long
)
/**
* 费率响应
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
*
* 注意:根据 TypeScript clob-client 源码,API 返回的字段名是 base_fee,而不是文档中的 fee_rate_bps
* 参考: clob-client/src/client.ts:312
*/
data class FeeRateResponse(
@SerializedName("base_fee")
val baseFee: Int // 费率基点(0 表示无费率,1000 表示 10%)
)
/**
* 最新价响应(从订单表获取)
*/
@@ -13,36 +13,54 @@ import retrofit2.http.Query
interface PolymarketGammaApi {
/**
* 根据 condition ID 列表获取市场信息
* 获取体育元数据信息
* 文档: https://docs.polymarket.com/api-reference/sports/get-sports-metadata-information
* @return 体育元数据数组
*/
@GET("/sports")
suspend fun getSports(): Response<List<SportsMetadataResponse>>
/**
* 根据条件获取市场信息
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x..."
* @param clobTokenIds CLOB token ID 数组(用于通过 tokenId 查询市场)
* @param includeTag 是否包含标签信息
* @param tags 标签 ID 数组,用于过滤市场(如 NBA 的 tag ID)
* @param active 是否只返回活跃的市场
* @param closed 是否包含已关闭的市场
* @param archived 是否包含已归档的市场
* @param limit 返回的市场数量限制
* @param startDateMin 最小开始日期(ISO 8601 格式,UTC 时区,如 "2025-12-01T00:00:00Z"
* @param sportsMarketTypes 体育市场类型数组(如 ["moneyline"] 用于筛选 moneyline 类型)
* @return 市场信息数组
*/
@GET("/markets")
suspend fun listMarkets(
@Query("condition_ids") conditionIds: List<String>? = null,
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
@Query("include_tag") includeTag: Boolean? = null
@Query("include_tag") includeTag: Boolean? = null,
@Query("tags") tags: List<String>? = null,
@Query("active") active: Boolean? = null,
@Query("closed") closed: Boolean? = null,
@Query("archived") archived: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("start_date_min") startDateMin: String? = null,
@Query("sports_market_types") sportsMarketTypes: List<String>? = null,
): Response<List<MarketResponse>>
}
/**
* 事件响应(从 MarketResponse.events 解析)
* 体育元数据响应
* 文档: https://docs.polymarket.com/api-reference/sports/get-sports-metadata-information
*/
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
data class SportsMetadataResponse(
val sport: String? = null, // 体育标识符或缩写(如 "NBA"
val image: String? = null, // 体育 logo 或图片 URL
val resolution: String? = null, // 官方决议源 URL
val ordering: String? = null, // 显示顺序(通常是 "home" 或 "away"
val tags: String? = null, // 逗号分隔的标签 ID 列表
val series: String? = null // 系列标识符
)
/**
@@ -71,9 +89,22 @@ 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 版本)
val tags: List<MarketTag>? = null, // 市场标签列表
val sportsMarketType: String? = null, // 市场类型:moneyline, spread 等
val gameStartTime: String? = null, // 比赛开始时间(格式:2025-12-13 00:00:00+00
val createdAt: String? = null, // 市场创建时间(ISO 8601 格式)
val resolutionSource: String? = null // 决议源 URL(如 "https://www.nba.com/"
)
/**
* 市场标签
*/
data class MarketTag(
val id: String? = null,
val label: String? = null,
val slug: String? = null
)
@@ -2,8 +2,8 @@ package com.wrbug.polymarketbot.config
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.util.createClient
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import retrofit2.Retrofit
@@ -23,6 +23,9 @@ class RetrofitConfig(
private val gson: Gson
) {
@Value("\${polymarket.clob.base-url}")
private lateinit var clobBaseUrl: String
/**
* 创建 CLOB API 客户端
* 用于跟单系统的订单操作和交易查询
@@ -35,7 +38,7 @@ class RetrofitConfig(
val okHttpClient = createClient().build()
return Retrofit.Builder()
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
@@ -1,48 +0,0 @@
package com.wrbug.polymarketbot.constants
/**
* Polymarket API 常量
* 集中管理所有 Polymarket API 的 URL 配置
*/
object PolymarketConstants {
/**
* Polymarket CLOB API 基础 URL
*/
const val CLOB_BASE_URL = "https://clob.polymarket.com"
/**
* Polymarket RTDS WebSocket URL
* 用于订单推送服务
*/
const val RTDS_WS_URL = "wss://ws-subscriptions-clob.polymarket.com"
/**
* Polymarket User Channel WebSocket URL
* 用于跟单服务(订阅 Leader 交易)
*/
const val USER_WS_URL = "wss://ws-live-data.polymarket.com"
/**
* Polymarket Activity WebSocket URL
* 用于 Activity 全局交易流监听
*/
const val ACTIVITY_WS_URL = "wss://ws-live-data.polymarket.com"
/**
* Polymarket Data API 基础 URL
*/
const val DATA_API_BASE_URL = "https://data-api.polymarket.com"
/**
* Polymarket Gamma API 基础 URL
*/
const val GAMMA_BASE_URL = "https://gamma-api.polymarket.com"
/**
* Builder Relayer API URL
* 用于 Gasless 交易
*/
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/"
}
@@ -23,50 +23,6 @@ class AccountController(
private val logger = LoggerFactory.getLogger(AccountController::class.java)
/**
* 检查代理地址选项(用于导入前选择代理类型)
*/
@PostMapping("/check-proxy-options")
fun checkProxyOptions(@RequestBody request: CheckProxyOptionsRequest): ResponseEntity<ApiResponse<CheckProxyOptionsResponse>> {
return try {
if (request.walletAddress.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_WALLET_ADDRESS_EMPTY, messageSource = messageSource))
}
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "必须提供私钥或助记词", messageSource))
}
val result = runBlocking { accountService.checkProxyOptions(request) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("检查代理地址选项失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
)
else -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_ERROR,
e.message,
messageSource
)
)
}
}
)
} catch (e: Exception) {
logger.error("检查代理地址选项异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 通过私钥导入账户
*/
@@ -89,17 +45,14 @@ class AccountController(
onFailure = { e ->
logger.error("导入账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
} else {
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
}
)
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
}
}
@@ -142,6 +95,65 @@ class AccountController(
}
}
/**
* 刷新账户的代理地址
* 使用最新的代理地址计算逻辑(支持 Magic 和 Safe 两种类型)
*/
@PostMapping("/refresh-proxy")
fun refreshProxyAddress(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountDto>> {
return try {
if (request.accountId == null || request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
}
val result = accountService.refreshProxyAddress(request.accountId)
result.fold(
onSuccess = { account ->
ResponseEntity.ok(ApiResponse.success(account))
},
onFailure = { e ->
logger.error("刷新代理地址失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
)
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
)
} catch (e: Exception) {
logger.error("刷新代理地址异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 批量刷新所有账户的代理地址
*/
@PostMapping("/refresh-all-proxies")
fun refreshAllProxyAddresses(): ResponseEntity<ApiResponse<List<AccountDto>>> {
return try {
val result = accountService.refreshAllProxyAddresses()
result.fold(
onSuccess = { accounts ->
ResponseEntity.ok(ApiResponse.success(accounts))
},
onFailure = { e ->
logger.error("批量刷新代理地址失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("批量刷新代理地址异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 删除账户
*/
@@ -2,7 +2,6 @@ package com.wrbug.polymarketbot.controller.auth
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.repository.UserRepository
import com.wrbug.polymarketbot.service.auth.AuthService
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
import jakarta.servlet.http.HttpServletRequest
@@ -19,8 +18,7 @@ import org.springframework.web.bind.annotation.*
class AuthController(
private val authService: AuthService,
private val messageSource: MessageSource,
private val webSocketTicketService: WebSocketTicketService,
private val userRepository: UserRepository
private val webSocketTicketService: WebSocketTicketService
) {
private val logger = LoggerFactory.getLogger(AuthController::class.java)
@@ -186,32 +184,5 @@ class AuthController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource))
}
}
/**
* 验证当前用户权限
* 用于动态更新服务验证管理员权限
* 管理员权限判断:是否为默认账户(isDefault == true
*/
@GetMapping("/verify")
fun verify(httpRequest: HttpServletRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
// 从请求属性中获取用户名(由 JWT 拦截器设置)
val username = httpRequest.getAttribute("username") as? String
if (username == null) {
return ResponseEntity.status(401).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource))
}
// 检查是否为默认账户(管理员)
val user = userRepository.findByUsername(username)
if (user == null || !user.isDefault) {
return ResponseEntity.status(403).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "需要管理员权限", messageSource))
}
ResponseEntity.ok(ApiResponse.success(Unit))
} catch (e: Exception) {
logger.error("验证权限异常: ${e.message}", e)
ResponseEntity.status(500).body(ApiResponse.error(ErrorCode.SERVER_ERROR, "验证失败", messageSource))
}
}
}
@@ -1,256 +0,0 @@
package com.wrbug.polymarketbot.controller.backtest
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.backtest.BacktestService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* 回测管理控制器
*/
@RestController
@RequestMapping("/api/backtest")
class BacktestController(
private val backtestService: BacktestService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(BacktestController::class.java)
/**
* 创建回测任务
*/
@PostMapping("/tasks")
fun createBacktestTask(@RequestBody request: BacktestCreateRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
return try {
logger.info("创建回测任务: taskName=${request.taskName}, leaderId=${request.leaderId}")
val result = runBlocking {
backtestService.createBacktestTask(request)
}
result.fold(
onSuccess = { dto ->
logger.info("回测任务创建成功: taskId=${dto.id}")
ResponseEntity.ok(ApiResponse.success(dto))
},
onFailure = { e ->
logger.error("创建回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.PARAM_ERROR
else -> ErrorCode.SERVER_BACKTEST_CREATE_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("创建回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_CREATE_FAILED, e.message, messageSource))
}
}
/**
* 查询回测任务列表
*/
@PostMapping("/tasks/list")
fun getBacktestTaskList(@RequestBody request: BacktestListRequest): ResponseEntity<ApiResponse<BacktestListResponse>> {
return try {
val result = backtestService.getBacktestTaskList(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测任务列表成功: total=${response.total}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测任务列表失败", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测任务列表异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询回测任务详情
*/
@PostMapping("/tasks/detail")
fun getBacktestTaskDetail(@RequestBody request: BacktestDetailRequest): ResponseEntity<ApiResponse<BacktestDetailResponse>> {
return try {
val result = backtestService.getBacktestTaskDetail(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测任务详情成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测任务详情失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
else -> ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测任务详情异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询回测交易记录
*/
@PostMapping("/tasks/trades")
fun getBacktestTrades(@RequestBody request: BacktestTradeListRequest): ResponseEntity<ApiResponse<BacktestTradeListResponse>> {
return try {
val result = backtestService.getBacktestTrades(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测交易记录成功: taskId=${request.taskId}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测交易记录失败", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测交易记录异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 删除回测任务
*/
@PostMapping("/tasks/delete")
fun deleteBacktestTask(@RequestBody request: BacktestDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("删除回测任务: taskId=${request.id}")
val result = backtestService.deleteBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务删除成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("删除回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_DELETE_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("删除回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DELETE_FAILED, e.message, messageSource))
}
}
/**
* 停止回测任务
*/
@PostMapping("/tasks/stop")
fun stopBacktestTask(@RequestBody request: BacktestStopRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("停止回测任务: taskId=${request.id}")
val result = backtestService.stopBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务停止成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("停止回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_STOP_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("停止回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_STOP_FAILED, e.message, messageSource))
}
}
/**
* 重试回测任务
*/
@PostMapping("/tasks/retry")
fun retryBacktestTask(@RequestBody request: BacktestRetryRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("重试回测任务: taskId=${request.id}")
val result = backtestService.retryBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务重试成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("重试回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_RETRY_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("重试回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RETRY_FAILED, e.message, messageSource))
}
}
/**
* 按当前配置重新测试:基于已完成的回测任务创建相同配置的新任务(仅支持已完成任务)
*/
@PostMapping("/tasks/rerun")
fun rerunBacktestTask(@RequestBody request: BacktestRerunRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
return try {
logger.info("按配置重新测试: sourceTaskId=${request.id}, newTaskName=${request.taskName}")
val result = backtestService.rerunBacktestTask(request)
result.fold(
onSuccess = { dto ->
logger.info("重新测试任务创建成功: newTaskId=${dto.id}")
ResponseEntity.ok(ApiResponse.success(dto))
},
onFailure = { e ->
logger.error("按配置重新测试失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_NOT_COMPLETED
else -> ErrorCode.SERVER_BACKTEST_RERUN_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("按配置重新测试异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RERUN_FAILED, e.message, messageSource))
}
}
}
@@ -140,7 +140,7 @@ class LeaderController(
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
val result = leaderService.getLeaderDetail(request.leaderId)
result.fold(
onSuccess = { leader ->
@@ -159,36 +159,6 @@ class LeaderController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询被跟单者余额
*/
@PostMapping("/balance")
fun getLeaderBalance(@RequestBody request: LeaderBalanceRequest): ResponseEntity<ApiResponse<LeaderBalanceResponse>> {
return try {
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
val result = leaderService.getLeaderBalance(request.leaderId)
result.fold(
onSuccess = { balance ->
ResponseEntity.ok(ApiResponse.success(balance))
},
onFailure = { e ->
logger.error("查询 Leader 余额失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
)
} catch (e: Exception) {
logger.error("查询 Leader 余额异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
/**
@@ -200,51 +200,5 @@ 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))
}
}
}
@@ -0,0 +1,69 @@
package com.wrbug.polymarketbot.controller.nba
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.nba.NbaGameService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* NBA 比赛控制器
*/
@RestController
@RequestMapping("/api/nba/games")
class NbaGameController(
private val nbaGameService: NbaGameService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(NbaGameController::class.java)
/**
* 获取 NBA 比赛列表
*/
@PostMapping("/list")
fun getNbaGames(@RequestBody request: NbaGameListRequest): ResponseEntity<ApiResponse<NbaGameListResponse>> {
return try {
val result = runBlocking {
nbaGameService.getNbaGames(request)
}
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取 NBA 比赛列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取 NBA 比赛列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 获取 7 天内的所有球队(用于策略配置)
*/
@PostMapping("/teams")
fun getTeamsInNext7Days(): ResponseEntity<ApiResponse<List<String>>> {
return try {
val result = runBlocking {
nbaGameService.getTeamsInNext7Days()
}
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取球队列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取球队列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
@@ -0,0 +1,70 @@
package com.wrbug.polymarketbot.controller.nba
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.nba.NbaMarketService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* NBA 市场控制器
*/
@RestController
@RequestMapping("/api/nba/markets")
class NbaMarketController(
private val nbaMarketService: NbaMarketService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(NbaMarketController::class.java)
/**
* 获取 NBA 市场列表
*/
@PostMapping("/list")
fun getNbaMarkets(@RequestBody request: NbaMarketListRequest): ResponseEntity<ApiResponse<NbaMarketListResponse>> {
return try {
val result = runBlocking {
nbaMarketService.getNbaMarkets(request)
}
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取 NBA 市场列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取 NBA 市场列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 从 NBA 市场中获取球队列表(用于策略配置)
* 从市场名称中解析出所有唯一的球队名称
*/
@PostMapping("/teams")
fun getTeamsFromMarkets(): ResponseEntity<ApiResponse<List<String>>> {
return try {
val result = runBlocking {
nbaMarketService.getTeamsFromMarkets(active = true)
}
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取球队列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取球队列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
@@ -0,0 +1,166 @@
package com.wrbug.polymarketbot.controller.nba
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.nba.NbaQuantitativeStrategyService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* NBA 量化策略控制器
*/
@RestController
@RequestMapping("/api/nba/strategies")
class NbaQuantitativeStrategyController(
private val strategyService: NbaQuantitativeStrategyService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(NbaQuantitativeStrategyController::class.java)
/**
* 创建策略
*/
@PostMapping("/create")
fun createStrategy(@RequestBody request: NbaQuantitativeStrategyCreateRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyDto>> {
return try {
if (request.strategyName.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, customMsg = "策略名称不能为空", messageSource = messageSource))
}
if (request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, customMsg = "账户ID无效", messageSource = messageSource))
}
val result = runBlocking { strategyService.createStrategy(request) }
result.fold(
onSuccess = { strategy ->
ResponseEntity.ok(ApiResponse.success(strategy))
},
onFailure = { e ->
logger.error("创建策略失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("创建策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 更新策略
*/
@PostMapping("/update")
fun updateStrategy(@RequestBody request: NbaQuantitativeStrategyUpdateRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyDto>> {
return try {
if (request.id <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, customMsg = "策略ID无效", messageSource = messageSource))
}
val result = runBlocking { strategyService.updateStrategy(request) }
result.fold(
onSuccess = { strategy ->
ResponseEntity.ok(ApiResponse.success(strategy))
},
onFailure = { e ->
logger.error("更新策略失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("更新策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 获取策略列表
*/
@PostMapping("/list")
fun getStrategyList(@RequestBody request: NbaQuantitativeStrategyListRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyListResponse>> {
return try {
val result = runBlocking { strategyService.getStrategyList(request) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("获取策略列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取策略列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 获取策略详情
*/
@PostMapping("/detail")
fun getStrategyDetail(@RequestBody request: NbaQuantitativeStrategyDetailRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyDto>> {
return try {
if (request.id == null || request.id <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, customMsg = "策略ID不能为空", messageSource = messageSource))
}
val result = runBlocking { strategyService.getStrategyDetail(request.id) }
result.fold(
onSuccess = { strategy ->
ResponseEntity.ok(ApiResponse.success(strategy))
},
onFailure = { e ->
logger.error("获取策略详情失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取策略详情异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 删除策略
*/
@PostMapping("/delete")
fun deleteStrategy(@RequestBody request: NbaQuantitativeStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
if (request.id == null || request.id <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, customMsg = "策略ID不能为空", messageSource = messageSource))
}
val result = runBlocking { strategyService.deleteStrategy(request.id) }
result.fold(
onSuccess = {
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("删除策略失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("删除策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
/**
* 策略详情请求
*/
data class NbaQuantitativeStrategyDetailRequest(
val id: Long?
)
/**
* 策略删除请求
*/
data class NbaQuantitativeStrategyDeleteRequest(
val id: Long?
)
@@ -11,37 +11,6 @@ data class AccountImportRequest(
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
)
/**
* 检查代理地址选项请求
*/
data class CheckProxyOptionsRequest(
val walletAddress: String, // EOA 地址(必需)
val privateKey: String? = null, // 私钥(加密,私钥导入时提供)
val mnemonic: String? = null // 助记词(加密,助记词导入时提供)
)
/**
* 代理地址选项信息
*/
data class ProxyOptionDto(
val walletType: String, // "magic" 或 "safe"
val proxyAddress: String, // 代理地址
val descriptionKey: String, // 说明文案的多语言 key(如 "accountImport.proxyOption.magic.description"
val availableBalance: String, // 可用余额
val positionBalance: String, // 仓位余额
val totalBalance: String, // 总余额
val positionCount: Int, // 持仓数量
val hasAssets: Boolean, // 是否有资产(余额>0 或持仓>0)
val error: String? = null // 获取失败时的错误信息(可选)
)
/**
* 检查代理地址选项响应
*/
data class CheckProxyOptionsResponse(
val options: List<ProxyOptionDto> // 代理地址选项列表(私钥导入返回2个,助记词返回1个)
)
/**
* 账户更新请求
*/
@@ -124,16 +93,6 @@ data class AccountListResponse(
val total: Long
)
/**
* 钱包余额响应通用类用于 Account Leader
*/
data class WalletBalanceResponse(
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
/**
* 账户余额响应
*/
@@ -149,7 +108,6 @@ data class AccountBalanceResponse(
*/
data class PositionDto(
val marketId: String,
val title: String?, // 市场名称
val side: String, // YES 或 NO
val quantity: String,
val avgPrice: String,
@@ -167,8 +125,7 @@ data class AccountPositionDto(
val proxyAddress: String,
val marketId: String,
val marketTitle: String?,
val marketSlug: String?, // 显示用的 slug
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketSlug: String?,
val marketIcon: String?, // 市场图标 URL
val side: String, // 结果名称(如 "YES", "NO", "Pakistan" 等)
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2...),用于计算 tokenId
@@ -1,64 +0,0 @@
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 // 交易者钱包地址 ⭐ 关键字段
)
@@ -1,211 +0,0 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
/**
* 回测任务创建请求
*/
data class BacktestCreateRequest(
val taskName: String, // 回测任务名称
val leaderId: Long, // Leader ID
val initialBalance: String, // 初始资金
val backtestDays: Int, // 回测天数 (1-30)
// 跟单配置(与 CopyTrading 一致,但不包含 max_position_count
val copyMode: String? = null, // "RATIO" 或 "FIXED"
val copyRatio: String? = null, // 仅在 copyMode="RATIO" 时生效
val fixedAmount: String? = null, // 仅在 copyMode="FIXED" 时生效
val maxOrderSize: String? = null,
val minOrderSize: String? = null,
val maxDailyLoss: String? = null,
val maxDailyOrders: Int? = null,
val supportSell: Boolean? = null,
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
)
/**
* 回测任务列表请求
*/
data class BacktestListRequest(
val leaderId: Long? = null, // Leader ID(可选)
val status: String? = null, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
val sortBy: String? = null, // profitAmount / profitRate / createdAt
val sortOrder: String? = null, // asc / desc
val page: Int = 1, // 页码,从1开始
val size: Int = 20 // 每页数量
)
/**
* 回测任务详情请求
*/
data class BacktestDetailRequest(
val id: Long // 回测任务ID
)
/**
* 回测交易记录请求
*/
data class BacktestTradeListRequest(
val taskId: Long, // 回测任务ID
val page: Int = 1, // 页码,从1开始
val size: Int = 20 // 每页数量
)
/**
* 回测进度查询请求
*/
data class BacktestProgressRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务停止请求
*/
data class BacktestStopRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务删除请求
*/
data class BacktestDeleteRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务重试请求
*/
data class BacktestRetryRequest(
val id: Long // 回测任务ID
)
/**
* 按当前配置重新测试请求仅支持已完成任务
*/
data class BacktestRerunRequest(
val id: Long, // 源回测任务ID
val taskName: String? = null // 新任务名称,为空时使用「原名称 (副本)」
)
/**
* 回测任务列表响应
*/
data class BacktestListResponse(
val list: List<BacktestTaskDto>,
val total: Long,
val page: Int,
val size: Int
)
/**
* 回测任务详情响应
*/
data class BacktestDetailResponse(
val task: BacktestTaskDto,
val config: BacktestConfigDto,
val statistics: BacktestStatisticsDto
)
/**
* 回测交易记录列表响应
*/
data class BacktestTradeListResponse(
val list: List<BacktestTradeDto>,
val total: Long,
val page: Int,
val size: Int
)
/**
* 回测进度响应
*/
data class BacktestProgressResponse(
val progress: Int, // 执行进度 (0-100)
val currentBalance: String, // 当前余额
val totalTrades: Int, // 总交易笔数
val status: String // 任务状态
)
/**
* 回测任务 DTO
*/
data class BacktestTaskDto(
val id: Long,
val taskName: String,
val leaderId: Long,
val leaderName: String?,
val leaderAddress: String?,
val initialBalance: String,
val finalBalance: String?,
val profitAmount: String?,
val profitRate: String?,
val backtestDays: Int,
val startTime: Long,
val endTime: Long?,
val status: String, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
val progress: Int,
val totalTrades: Int,
val createdAt: Long,
val executionStartedAt: Long?,
val executionFinishedAt: Long?
)
/**
* 回测配置 DTO
*/
data class BacktestConfigDto(
val copyMode: String,
val copyRatio: String,
val fixedAmount: String?,
val maxOrderSize: String,
val minOrderSize: String,
val maxDailyLoss: String,
val maxDailyOrders: Int,
val supportSell: Boolean,
val keywordFilterMode: String?,
val keywords: List<String>?,
val maxPositionValue: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? // 最高价格(可选),NULL表示不限制最高价
)
/**
* 回测统计信息 DTO
*/
data class BacktestStatisticsDto(
val totalTrades: Int, // 总交易笔数
val buyTrades: Int, // 买入笔数
val sellTrades: Int, // 卖出笔数
val winTrades: Int, // 盈利交易笔数
val lossTrades: Int, // 亏损交易笔数
val winRate: String, // 胜率(%)
val maxProfit: String, // 最大单笔盈利
val maxLoss: String, // 最大单笔亏损
val maxDrawdown: String, // 最大回撤
val avgHoldingTime: Long? // 平均持仓时间(毫秒)
)
/**
* 回测交易记录 DTO
*/
data class BacktestTradeDto(
val id: Long,
val tradeTime: Long,
val marketId: String,
val marketTitle: String?,
val side: String, // BUY/SELL/SETTLEMENT
val outcome: String,
val outcomeIndex: Int?,
val quantity: String,
val price: String,
val amount: String,
val fee: String,
val profitLoss: String?,
val balanceAfter: String,
val leaderTradeId: String?
)
@@ -36,14 +36,10 @@ data class CopyTradingCreateRequest(
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 新增配置字段
val configName: String? = null, // 配置名(可选)
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
)
/**
@@ -74,14 +70,10 @@ data class CopyTradingUpdateRequest(
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 新增配置字段
val configName: String? = null, // 配置名(可选,但提供时必须非空)
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
)
/**
@@ -149,14 +141,10 @@ data class CopyTradingDto(
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 新增配置字段
val configName: String? = null, // 配置名(可选)
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
val createdAt: Long,
val updatedAt: Long
)
@@ -40,10 +40,6 @@ 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,
@@ -61,10 +57,6 @@ 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,
@@ -79,11 +71,6 @@ 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,
@@ -110,60 +97,12 @@ data class OrderTrackingRequest(
val page: Int? = 1,
val limit: Int? = 20,
val marketId: String? = null,
val marketTitle: String? = null, // 市场标题关键字筛选
val side: String? = null,
val status: String? = null,
val sellOrderId: String? = null,
val buyOrderId: String? = null
)
/**
* 按市场分组的订单查询请求
*/
data class MarketGroupedOrdersRequest(
val copyTradingId: Long,
val type: String, // buy, sell, matched
val page: Int? = 1,
val limit: Int? = 20,
val marketId: String? = null,
val marketTitle: String? = null
)
/**
* 单个市场的订单统计信息
*/
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
)
/**
* 统计查询请求
*/
@@ -23,8 +23,7 @@ data class TemplateCreateRequest(
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
/**
@@ -51,8 +50,7 @@ data class TemplateUpdateRequest(
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
/**
@@ -86,8 +84,7 @@ data class TemplateCopyRequest(
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
/**
@@ -122,7 +119,6 @@ data class TemplateDto(
val maxSpread: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
val pushFilteredOrders: Boolean, // 推送已过滤订单(默认关闭)
val createdAt: Long,
val updatedAt: Long
)
@@ -36,13 +36,6 @@ data class LeaderListRequest(
val category: String? = null // sports 或 crypto
)
/**
* Leader 余额请求
*/
data class LeaderBalanceRequest(
val leaderId: Long // LeaderID(必需)
)
/**
* Leader 信息响应
*/
@@ -54,7 +47,6 @@ data class LeaderDto(
val remark: String? = null, // Leader 备注(可选)
val website: String? = null, // Leader 网站(可选)
val copyTradingCount: Long = 0, // 跟单关系数量
val backtestCount: Long = 0, // 回测数量
val totalOrders: Long? = null, // 总订单数(可选)
val totalPnl: String? = null, // 总盈亏(可选)
val createdAt: Long,
@@ -69,16 +61,3 @@ data class LeaderListResponse(
val total: Long
)
/**
* Leader 余额响应
*/
data class LeaderBalanceResponse(
val leaderId: Long,
val leaderAddress: String,
val leaderName: String?,
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
@@ -25,8 +25,7 @@ data class MarketDto(
val liquidityNum: Double?,
val bestBid: Double?,
val bestAsk: Double?,
val lastTradePrice: Double?,
val events: List<MarketDto>? = null // 事件列表(从 events[0] 获取 slug
val lastTradePrice: Double?
)
/**
@@ -38,9 +37,9 @@ data class OutcomeDto(
)
/**
* 事件 DTO用于其他 API 调用
* 事件 DTO
*/
data class EventListDto(
data class EventDto(
val id: String,
val title: String,
val category: String,
@@ -56,7 +55,7 @@ data class SeriesDto(
val id: String,
val title: String,
val category: String,
val events: List<EventListDto>?,
val events: List<EventDto>?,
val createdAt: Long? // 时间戳(毫秒)
)
@@ -71,3 +70,4 @@ data class CommentDto(
val createdAt: Long, // 时间戳(毫秒)
val user: String?
)
@@ -0,0 +1,40 @@
package com.wrbug.polymarketbot.dto
import java.time.LocalDate
/**
* NBA 比赛 DTO
*/
data class NbaGameDto(
val id: Long?,
val nbaGameId: String?,
val homeTeam: String,
val awayTeam: String,
val gameDate: LocalDate,
val gameTime: Long?,
val gameStatus: String,
val homeScore: Int,
val awayScore: Int,
val period: Int,
val timeRemaining: String?,
val polymarketMarketId: String?
)
/**
* NBA 比赛列表响应
*/
data class NbaGameListResponse(
val list: List<NbaGameDto>,
val total: Long
)
/**
* NBA 比赛列表请求
* 前端传递时间戳毫秒后端转换为西8区时间
*/
data class NbaGameListRequest(
val startTimestamp: Long? = null, // 开始时间戳(毫秒)
val endTimestamp: Long? = null, // 结束时间戳(毫秒)
val gameStatus: String? = null
)
@@ -0,0 +1,45 @@
package com.wrbug.polymarketbot.dto
/**
* NBA 市场 DTO
*/
data class NbaMarketDto(
val id: String? = null,
val question: String? = null,
val conditionId: String? = null,
val slug: String? = null,
val description: String? = null,
val category: String? = null,
val active: Boolean? = null,
val closed: Boolean? = null,
val archived: Boolean? = null,
val volume: String? = null,
val liquidity: String? = null,
val endDate: String? = null,
val startDate: String? = null,
val outcomes: String? = null,
val outcomePrices: String? = null,
val volumeNum: Double? = null,
val liquidityNum: Double? = null,
val lastTradePrice: Double? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null
)
/**
* NBA 市场列表响应
*/
data class NbaMarketListResponse(
val list: List<NbaMarketDto>,
val total: Long
)
/**
* NBA 市场列表请求
*/
data class NbaMarketListRequest(
val active: Boolean? = true,
val closed: Boolean? = false,
val archived: Boolean? = false
)
@@ -0,0 +1,213 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
import java.time.LocalDate
/**
* NBA 量化策略 DTO
*/
data class NbaQuantitativeStrategyDto(
val id: Long?,
val strategyName: String,
val strategyDescription: String?,
val accountId: Long,
val accountName: String?,
val enabled: Boolean,
val filterTeams: List<String>?,
val filterDateFrom: LocalDate?,
val filterDateTo: LocalDate?,
val filterGameImportance: String?,
val minWinProbabilityDiff: BigDecimal,
val minWinProbability: BigDecimal?,
val maxWinProbability: BigDecimal?,
val minTradeValue: BigDecimal,
val minRemainingTime: Int?,
val maxRemainingTime: Int?,
val minScoreDiff: Int?,
val maxScoreDiff: Int?,
val buyAmountStrategy: String,
val fixedBuyAmount: BigDecimal?,
val buyRatio: BigDecimal?,
val baseBuyAmount: BigDecimal?,
val buyTiming: String,
val delayBuySeconds: Int,
val buyDirection: String,
val enableSell: Boolean,
val takeProfitThreshold: BigDecimal?,
val stopLossThreshold: BigDecimal?,
val probabilityReversalThreshold: BigDecimal?,
val sellRatio: BigDecimal,
val sellTiming: String,
val delaySellSeconds: Int,
val priceStrategy: String,
val fixedPrice: BigDecimal?,
val priceOffset: BigDecimal,
val maxPosition: BigDecimal,
val minPosition: BigDecimal,
val maxGamePosition: BigDecimal?,
val maxDailyLoss: BigDecimal?,
val maxDailyOrders: Int?,
val maxDailyProfit: BigDecimal?,
val priceTolerance: BigDecimal,
val minProbabilityThreshold: BigDecimal?,
val maxProbabilityThreshold: BigDecimal?,
val baseStrengthWeight: BigDecimal,
val recentFormWeight: BigDecimal,
val lineupIntegrityWeight: BigDecimal,
val starStatusWeight: BigDecimal,
val environmentWeight: BigDecimal,
val matchupAdvantageWeight: BigDecimal,
val scoreDiffWeight: BigDecimal,
val momentumWeight: BigDecimal,
val dataUpdateFrequency: Int,
val analysisFrequency: Int,
val pushFailedOrders: Boolean,
val pushFrequency: String,
val batchPushInterval: Int,
val createdAt: Long,
val updatedAt: Long
)
/**
* NBA 量化策略创建请求
*/
data class NbaQuantitativeStrategyCreateRequest(
val strategyName: String,
val strategyDescription: String? = null,
val accountId: Long,
val enabled: Boolean = true,
val filterTeams: List<String>? = null,
val filterDateFrom: LocalDate? = null,
val filterDateTo: LocalDate? = null,
val filterGameImportance: String? = null,
val minWinProbabilityDiff: BigDecimal? = null,
val minWinProbability: BigDecimal? = null,
val maxWinProbability: BigDecimal? = null,
val minTradeValue: BigDecimal? = null,
val minRemainingTime: Int? = null,
val maxRemainingTime: Int? = null,
val minScoreDiff: Int? = null,
val maxScoreDiff: Int? = null,
val buyAmountStrategy: String? = null,
val fixedBuyAmount: BigDecimal? = null,
val buyRatio: BigDecimal? = null,
val baseBuyAmount: BigDecimal? = null,
val buyTiming: String? = null,
val delayBuySeconds: Int? = null,
val buyDirection: String? = null,
val enableSell: Boolean? = null,
val takeProfitThreshold: BigDecimal? = null,
val stopLossThreshold: BigDecimal? = null,
val probabilityReversalThreshold: BigDecimal? = null,
val sellRatio: BigDecimal? = null,
val sellTiming: String? = null,
val delaySellSeconds: Int? = null,
val priceStrategy: String? = null,
val fixedPrice: BigDecimal? = null,
val priceOffset: BigDecimal? = null,
val maxPosition: BigDecimal? = null,
val minPosition: BigDecimal? = null,
val maxGamePosition: BigDecimal? = null,
val maxDailyLoss: BigDecimal? = null,
val maxDailyOrders: Int? = null,
val maxDailyProfit: BigDecimal? = null,
val priceTolerance: BigDecimal? = null,
val minProbabilityThreshold: BigDecimal? = null,
val maxProbabilityThreshold: BigDecimal? = null,
val baseStrengthWeight: BigDecimal? = null,
val recentFormWeight: BigDecimal? = null,
val lineupIntegrityWeight: BigDecimal? = null,
val starStatusWeight: BigDecimal? = null,
val environmentWeight: BigDecimal? = null,
val matchupAdvantageWeight: BigDecimal? = null,
val scoreDiffWeight: BigDecimal? = null,
val momentumWeight: BigDecimal? = null,
val dataUpdateFrequency: Int? = null,
val analysisFrequency: Int? = null,
val pushFailedOrders: Boolean? = null,
val pushFrequency: String? = null,
val batchPushInterval: Int? = null
)
/**
* NBA 量化策略更新请求
*/
data class NbaQuantitativeStrategyUpdateRequest(
val id: Long,
val strategyName: String? = null,
val strategyDescription: String? = null,
val enabled: Boolean? = null,
val filterTeams: List<String>? = null,
val filterDateFrom: LocalDate? = null,
val filterDateTo: LocalDate? = null,
val filterGameImportance: String? = null,
val minWinProbabilityDiff: BigDecimal? = null,
val minWinProbability: BigDecimal? = null,
val maxWinProbability: BigDecimal? = null,
val minTradeValue: BigDecimal? = null,
val minRemainingTime: Int? = null,
val maxRemainingTime: Int? = null,
val minScoreDiff: Int? = null,
val maxScoreDiff: Int? = null,
val buyAmountStrategy: String? = null,
val fixedBuyAmount: BigDecimal? = null,
val buyRatio: BigDecimal? = null,
val baseBuyAmount: BigDecimal? = null,
val buyTiming: String? = null,
val delayBuySeconds: Int? = null,
val buyDirection: String? = null,
val enableSell: Boolean? = null,
val takeProfitThreshold: BigDecimal? = null,
val stopLossThreshold: BigDecimal? = null,
val probabilityReversalThreshold: BigDecimal? = null,
val sellRatio: BigDecimal? = null,
val sellTiming: String? = null,
val delaySellSeconds: Int? = null,
val priceStrategy: String? = null,
val fixedPrice: BigDecimal? = null,
val priceOffset: BigDecimal? = null,
val maxPosition: BigDecimal? = null,
val minPosition: BigDecimal? = null,
val maxGamePosition: BigDecimal? = null,
val maxDailyLoss: BigDecimal? = null,
val maxDailyOrders: Int? = null,
val maxDailyProfit: BigDecimal? = null,
val priceTolerance: BigDecimal? = null,
val minProbabilityThreshold: BigDecimal? = null,
val maxProbabilityThreshold: BigDecimal? = null,
val baseStrengthWeight: BigDecimal? = null,
val recentFormWeight: BigDecimal? = null,
val lineupIntegrityWeight: BigDecimal? = null,
val starStatusWeight: BigDecimal? = null,
val environmentWeight: BigDecimal? = null,
val matchupAdvantageWeight: BigDecimal? = null,
val scoreDiffWeight: BigDecimal? = null,
val momentumWeight: BigDecimal? = null,
val dataUpdateFrequency: Int? = null,
val analysisFrequency: Int? = null,
val pushFailedOrders: Boolean? = null,
val pushFrequency: String? = null,
val batchPushInterval: Int? = null
)
/**
* NBA 量化策略列表请求
*/
data class NbaQuantitativeStrategyListRequest(
val accountId: Long? = null,
val enabled: Boolean? = null,
val strategyName: String? = null,
val page: Int? = 1,
val limit: Int? = 20
)
/**
* NBA 量化策略列表响应
*/
data class NbaQuantitativeStrategyListResponse(
val list: List<NbaQuantitativeStrategyDto>,
val total: Long,
val page: Int,
val limit: Int
)
@@ -1,32 +0,0 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
/**
* 用户交易数据
* 用于回测功能 Polymarket API 获取的用户交易历史
*/
data class TradeData(
val tradeId: String, // 交易 ID
val marketId: String, // 市场 ID
val marketTitle: String?, // 市场标题
val marketSlug: String?, // 市场 Slug
val side: String, // 交易方向: BUY/SELL
val outcome: String, // 结果: YES/NO 或 outcomeIndex
val outcomeIndex: Int?, // 结果索引
val price: BigDecimal, // 成交价格
val size: BigDecimal, // 成交数量
val amount: BigDecimal, // 成交金额
val timestamp: Long // 交易时间戳
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is TradeData) return false
return tradeId == other.tradeId
}
override fun hashCode(): Int {
return tradeId.hashCode()
}
}
@@ -16,11 +16,11 @@ data class Account(
@Column(name = "private_key", nullable = false, length = 500)
val privateKey: String, // 私钥(AES 加密存储)
@Column(name = "wallet_address", nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导),同一 EOA 可有多个账户(不同代理类型)
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导)
@Column(name = "proxy_address", unique = true, nullable = false, length = 42)
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须),唯一
@Column(name = "proxy_address", nullable = false, length = 42)
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
@Column(name = "api_key", length = 500)
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
@@ -1,151 +0,0 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import com.wrbug.polymarketbot.util.toSafeBigDecimal
/**
* 回测任务实体
*/
@Entity
@Table(name = "backtest_task")
data class BacktestTask(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "task_name", nullable = false, length = 100)
val taskName: String,
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
// 回测参数
@Column(name = "initial_balance", nullable = false, precision = 20, scale = 8)
val initialBalance: BigDecimal,
@Column(name = "final_balance", precision = 20, scale = 8)
var finalBalance: BigDecimal? = null,
@Column(name = "profit_amount", precision = 20, scale = 8)
var profitAmount: BigDecimal? = null,
@Column(name = "profit_rate", precision = 10, scale = 4)
var profitRate: BigDecimal? = null, // 收益率(%)
@Column(name = "backtest_days", nullable = false)
val backtestDays: Int,
@Column(name = "start_time", nullable = false)
val startTime: Long, // 回测开始时间(历史时间),创建时计算;执行时以当前时间为基准用局部变量重算窗口
@Column(name = "end_time")
var endTime: Long? = null, // 回测结束时间(历史时间)
// 跟单配置 (复制CopyTrading表结构,但不包含 max_position_count)
@Column(name = "copy_mode", nullable = false, length = 10)
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
val copyRatio: BigDecimal = BigDecimal.ONE,
@Column(name = "fixed_amount", precision = 20, scale = 8)
val fixedAmount: BigDecimal? = null,
@Column(name = "max_order_size", nullable = false, precision = 20, scale = 8)
val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(),
@Column(name = "min_order_size", nullable = false, precision = 20, scale = 8)
val minOrderSize: BigDecimal = "1".toSafeBigDecimal(),
@Column(name = "max_daily_loss", nullable = false, precision = 20, scale = 8)
val maxDailyLoss: BigDecimal = "10000".toSafeBigDecimal(),
@Column(name = "max_daily_orders", nullable = false)
val maxDailyOrders: Int = 100,
@Column(name = "support_sell", nullable = false)
val supportSell: Boolean = true,
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
val keywordFilterMode: String = "DISABLED", // DISABLED/WHITELIST/BLACKLIST
@Column(name = "keywords", columnDefinition = "JSON")
val keywords: String? = null,
@Column(name = "max_position_value", precision = 20, scale = 8)
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
@Column(name = "min_price", precision = 20, scale = 8)
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
@Column(name = "max_price", precision = 20, scale = 8)
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
// 统计字段
@Column(name = "avg_holding_time")
var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒)
@Column(name = "data_source", length = 50)
var dataSource: String = "MIXED", // INTERNAL/API/MIXED
// 执行状态
@Column(name = "status", nullable = false, length = 20)
var status: String = "PENDING", // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
@Column(name = "progress", nullable = false)
var progress: Int = 0, // 执行进度(0-100)
@Column(name = "total_trades", nullable = false)
var totalTrades: Int = 0,
@Column(name = "buy_trades", nullable = false)
var buyTrades: Int = 0,
@Column(name = "sell_trades", nullable = false)
var sellTrades: Int = 0,
@Column(name = "win_trades", nullable = false)
var winTrades: Int = 0,
@Column(name = "loss_trades", nullable = false)
var lossTrades: Int = 0,
@Column(name = "win_rate", precision = 5, scale = 2)
var winRate: BigDecimal? = null, // 胜率(%)
@Column(name = "max_profit", precision = 20, scale = 8)
var maxProfit: BigDecimal? = null, // 最大单笔盈利
@Column(name = "max_loss", precision = 20, scale = 8)
var maxLoss: BigDecimal? = null, // 最大单笔亏损
@Column(name = "max_drawdown", precision = 20, scale = 8)
var maxDrawdown: BigDecimal? = null, // 最大回撤
@Column(name = "error_message", columnDefinition = "TEXT")
var errorMessage: String? = null,
// 时间字段
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "execution_started_at")
var executionStartedAt: Long? = null,
@Column(name = "execution_finished_at")
var executionFinishedAt: Long? = null,
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis(),
@Column(name = "last_processed_trade_time")
var lastProcessedTradeTime: Long? = null,
@Column(name = "last_processed_trade_index")
var lastProcessedTradeIndex: Int? = null,
@Column(name = "processed_trade_count")
var processedTradeCount: Int = 0
)
@@ -1,62 +0,0 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* 回测交易记录实体
* 用于记录回测过程中的每笔模拟交易
*/
@Entity
@Table(name = "backtest_trade")
data class BacktestTrade(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "backtest_task_id", nullable = false)
val backtestTaskId: Long,
@Column(name = "trade_time", nullable = false)
val tradeTime: Long,
@Column(name = "market_id", nullable = false, length = 100)
val marketId: String,
@Column(name = "market_title", length = 500)
val marketTitle: String? = null,
@Column(name = "side", nullable = false, length = 20)
val side: String, // BUY/SELL/SETTLEMENT
@Column(name = "outcome", nullable = false, length = 50)
val outcome: String, // YES/NO 或 outcomeIndex
@Column(name = "outcome_index")
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
val quantity: BigDecimal,
@Column(name = "price", nullable = false, precision = 20, scale = 8)
val price: BigDecimal,
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
val amount: BigDecimal,
@Column(name = "fee", nullable = false, precision = 20, scale = 8)
val fee: BigDecimal = BigDecimal.ZERO, // 手续费(回测不计算,默认为0
@Column(name = "profit_loss", precision = 20, scale = 8)
val profitLoss: BigDecimal? = null, // 盈亏(仅卖出时)
@Column(name = "balance_after", nullable = false, precision = 20, scale = 8)
val balanceAfter: BigDecimal, // 交易后余额
@Column(name = "leader_trade_id", length = 100)
val leaderTradeId: String? = null, // Leader 原始交易ID
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@@ -58,10 +58,7 @@ data class CopyOrderTracking(
@Column(name = "notification_sent", nullable = false)
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
@Column(name = "source", nullable = false, length = 20)
val source: String, // 订单来源:activity-wsPolymarket WebSocket)、onchain-wsOnChain WebSocket
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -8,7 +8,12 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
* 跟单配置实体独立配置不再绑定模板
*/
@Entity
@Table(name = "copy_trading")
@Table(
name = "copy_trading",
uniqueConstraints = [
UniqueConstraint(columnNames = ["account_id", "leader_id"])
]
)
data class CopyTrading(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -27,7 +32,7 @@ data class CopyTrading(
@Column(name = "copy_mode", nullable = false, length = 10)
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
@Column(name = "fixed_amount", precision = 20, scale = 8)
@@ -83,12 +88,8 @@ data class CopyTrading(
@Column(name = "max_position_value", precision = 20, scale = 8)
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),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 = "max_position_count")
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 新增配置字段
@Column(name = "config_name", length = 255)
@@ -97,12 +98,6 @@ data class CopyTrading(
@Column(name = "push_failed_orders", nullable = false)
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
@Column(name = "push_filtered_orders", nullable = false)
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
@Column(name = "max_market_end_date")
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -20,7 +20,7 @@ data class CopyTradingTemplate(
@Column(name = "copy_mode", nullable = false, length = 10)
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
@Column(name = "fixed_amount", precision = 20, scale = 8)
@@ -72,9 +72,6 @@ data class CopyTradingTemplate(
@Column(name = "max_price", precision = 20, scale = 8)
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
@Column(name = "push_filtered_orders", nullable = false)
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -1,60 +0,0 @@
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()
)
@@ -0,0 +1,55 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.time.LocalDate
/**
* NBA 比赛实体
*/
@Entity
@Table(name = "nba_games")
data class NbaGame(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "nba_game_id", unique = true, length = 100)
val nbaGameId: String? = null,
@Column(name = "home_team", nullable = false, length = 100)
val homeTeam: String,
@Column(name = "away_team", nullable = false, length = 100)
val awayTeam: String,
@Column(name = "game_date", nullable = false)
val gameDate: LocalDate,
@Column(name = "game_time")
val gameTime: Long? = null,
@Column(name = "game_status", length = 50)
val gameStatus: String = "scheduled",
@Column(name = "home_score")
val homeScore: Int = 0,
@Column(name = "away_score")
val awayScore: Int = 0,
@Column(name = "period")
val period: Int = 0,
@Column(name = "time_remaining", length = 50)
val timeRemaining: String? = null,
@Column(name = "polymarket_market_id", length = 100)
val polymarketMarketId: String? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,63 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* NBA 市场实体Polymarket 市场信息
*/
@Entity
@Table(name = "nba_markets")
data class NbaMarket(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "polymarket_market_id", unique = true, nullable = false, length = 100)
val polymarketMarketId: String,
@Column(name = "condition_id", unique = true, nullable = false, length = 100)
val conditionId: String,
@Column(name = "market_slug", length = 255)
val marketSlug: String? = null,
@Column(name = "market_question", columnDefinition = "TEXT")
val marketQuestion: String? = null,
@Column(name = "market_description", columnDefinition = "TEXT")
val marketDescription: String? = null,
@Column(name = "category", length = 50)
val category: String = "sports",
@Column(name = "active")
val active: Boolean = true,
@Column(name = "closed")
val closed: Boolean = false,
@Column(name = "archived")
val archived: Boolean = false,
@Column(name = "volume", length = 50)
val volume: String? = null,
@Column(name = "liquidity", length = 50)
val liquidity: String? = null,
@Column(name = "outcomes", columnDefinition = "TEXT")
val outcomes: String? = null,
@Column(name = "end_date", length = 50)
val endDate: String? = null,
@Column(name = "start_date", length = 50)
val startDate: String? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,196 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import java.time.LocalDate
/**
* NBA 量化策略配置实体
*/
@Entity
@Table(name = "nba_quantitative_strategies")
data class NbaQuantitativeStrategy(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "strategy_name", nullable = false, length = 100)
val strategyName: String,
@Column(name = "strategy_description", columnDefinition = "TEXT")
val strategyDescription: String? = null,
@Column(name = "account_id", nullable = false)
val accountId: Long,
@Column(name = "enabled")
val enabled: Boolean = true,
// 比赛筛选参数
@Column(name = "filter_teams", columnDefinition = "TEXT")
val filterTeams: String? = null,
@Column(name = "filter_date_from")
val filterDateFrom: LocalDate? = null,
@Column(name = "filter_date_to")
val filterDateTo: LocalDate? = null,
@Column(name = "filter_game_importance", length = 50)
val filterGameImportance: String? = null,
// 触发条件参数
@Column(name = "min_win_probability_diff", precision = 5, scale = 4)
val minWinProbabilityDiff: BigDecimal = BigDecimal("0.1"),
@Column(name = "min_win_probability", precision = 5, scale = 4)
val minWinProbability: BigDecimal? = null,
@Column(name = "max_win_probability", precision = 5, scale = 4)
val maxWinProbability: BigDecimal? = null,
@Column(name = "min_trade_value", precision = 5, scale = 4)
val minTradeValue: BigDecimal = BigDecimal("0.05"),
@Column(name = "min_remaining_time")
val minRemainingTime: Int? = null,
@Column(name = "max_remaining_time")
val maxRemainingTime: Int? = null,
@Column(name = "min_score_diff")
val minScoreDiff: Int? = null,
@Column(name = "max_score_diff")
val maxScoreDiff: Int? = null,
// 买入规则参数
@Column(name = "buy_amount_strategy", length = 20)
val buyAmountStrategy: String = "FIXED",
@Column(name = "fixed_buy_amount", precision = 20, scale = 8)
val fixedBuyAmount: BigDecimal? = null,
@Column(name = "buy_ratio", precision = 5, scale = 4)
val buyRatio: BigDecimal? = null,
@Column(name = "base_buy_amount", precision = 20, scale = 8)
val baseBuyAmount: BigDecimal? = null,
@Column(name = "buy_timing", length = 20)
val buyTiming: String = "IMMEDIATE",
@Column(name = "delay_buy_seconds")
val delayBuySeconds: Int = 0,
@Column(name = "buy_direction", length = 10)
val buyDirection: String = "AUTO",
// 卖出规则参数
@Column(name = "enable_sell")
val enableSell: Boolean = true,
@Column(name = "take_profit_threshold", precision = 5, scale = 4)
val takeProfitThreshold: BigDecimal? = null,
@Column(name = "stop_loss_threshold", precision = 5, scale = 4)
val stopLossThreshold: BigDecimal? = null,
@Column(name = "probability_reversal_threshold", precision = 5, scale = 4)
val probabilityReversalThreshold: BigDecimal? = null,
@Column(name = "sell_ratio", precision = 5, scale = 4)
val sellRatio: BigDecimal = BigDecimal("1.0"),
@Column(name = "sell_timing", length = 20)
val sellTiming: String = "IMMEDIATE",
@Column(name = "delay_sell_seconds")
val delaySellSeconds: Int = 0,
// 价格策略参数
@Column(name = "price_strategy", length = 20)
val priceStrategy: String = "MARKET",
@Column(name = "fixed_price", precision = 5, scale = 4)
val fixedPrice: BigDecimal? = null,
@Column(name = "price_offset", precision = 5, scale = 4)
val priceOffset: BigDecimal = BigDecimal.ZERO,
// 风险控制参数
@Column(name = "max_position", precision = 20, scale = 8)
val maxPosition: BigDecimal = BigDecimal("50"),
@Column(name = "min_position", precision = 20, scale = 8)
val minPosition: BigDecimal = BigDecimal("5"),
@Column(name = "max_game_position", precision = 20, scale = 8)
val maxGamePosition: BigDecimal? = null,
@Column(name = "max_daily_loss", precision = 20, scale = 8)
val maxDailyLoss: BigDecimal? = null,
@Column(name = "max_daily_orders")
val maxDailyOrders: Int? = null,
@Column(name = "max_daily_profit", precision = 20, scale = 8)
val maxDailyProfit: BigDecimal? = null,
@Column(name = "price_tolerance", precision = 5, scale = 4)
val priceTolerance: BigDecimal = BigDecimal("0.05"),
@Column(name = "min_probability_threshold", precision = 5, scale = 4)
val minProbabilityThreshold: BigDecimal? = null,
@Column(name = "max_probability_threshold", precision = 5, scale = 4)
val maxProbabilityThreshold: BigDecimal? = null,
// 算法权重参数
@Column(name = "base_strength_weight", precision = 5, scale = 4)
val baseStrengthWeight: BigDecimal = BigDecimal("0.3"),
@Column(name = "recent_form_weight", precision = 5, scale = 4)
val recentFormWeight: BigDecimal = BigDecimal("0.25"),
@Column(name = "lineup_integrity_weight", precision = 5, scale = 4)
val lineupIntegrityWeight: BigDecimal = BigDecimal("0.2"),
@Column(name = "star_status_weight", precision = 5, scale = 4)
val starStatusWeight: BigDecimal = BigDecimal("0.15"),
@Column(name = "environment_weight", precision = 5, scale = 4)
val environmentWeight: BigDecimal = BigDecimal("0.1"),
@Column(name = "matchup_advantage_weight", precision = 5, scale = 4)
val matchupAdvantageWeight: BigDecimal = BigDecimal("0.2"),
@Column(name = "score_diff_weight", precision = 5, scale = 4)
val scoreDiffWeight: BigDecimal = BigDecimal("0.3"),
@Column(name = "momentum_weight", precision = 5, scale = 4)
val momentumWeight: BigDecimal = BigDecimal("0.2"),
// 系统配置参数
@Column(name = "data_update_frequency")
val dataUpdateFrequency: Int = 30,
@Column(name = "analysis_frequency")
val analysisFrequency: Int = 30,
@Column(name = "push_failed_orders")
val pushFailedOrders: Boolean = false,
@Column(name = "push_frequency", length = 20)
val pushFrequency: String = "REALTIME",
@Column(name = "batch_push_interval")
val batchPushInterval: Int = 1,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,50 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import java.time.LocalDate
/**
* NBA 策略执行统计实体
*/
@Entity
@Table(name = "nba_strategy_statistics")
data class NbaStrategyStatistics(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "strategy_id", nullable = false)
val strategyId: Long,
@Column(name = "stat_date", nullable = false)
val statDate: LocalDate,
@Column(name = "total_signals")
val totalSignals: Int = 0,
@Column(name = "buy_signals")
val buySignals: Int = 0,
@Column(name = "sell_signals")
val sellSignals: Int = 0,
@Column(name = "success_signals")
val successSignals: Int = 0,
@Column(name = "failed_signals")
val failedSignals: Int = 0,
@Column(name = "total_profit", precision = 20, scale = 8)
val totalProfit: BigDecimal = BigDecimal.ZERO,
@Column(name = "total_volume", precision = 20, scale = 8)
val totalVolume: BigDecimal = BigDecimal.ZERO,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,64 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* NBA 交易信号实体
*/
@Entity
@Table(name = "nba_trading_signals")
data class NbaTradingSignal(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "strategy_id", nullable = false)
val strategyId: Long,
@Column(name = "game_id")
val gameId: Long? = null,
@Column(name = "market_id")
val marketId: Long? = null,
@Column(name = "signal_type", nullable = false, length = 10)
val signalType: String,
@Column(name = "direction", nullable = false, length = 10)
val direction: String,
@Column(name = "price", nullable = false, precision = 5, scale = 4)
val price: BigDecimal,
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
val quantity: BigDecimal,
@Column(name = "total_amount", nullable = false, precision = 20, scale = 8)
val totalAmount: BigDecimal,
@Column(name = "reason", columnDefinition = "TEXT")
val reason: String? = null,
@Column(name = "win_probability", precision = 5, scale = 4)
val winProbability: BigDecimal? = null,
@Column(name = "trade_value", precision = 5, scale = 4)
val tradeValue: BigDecimal? = null,
@Column(name = "signal_status", length = 20)
val signalStatus: String = "GENERATED",
@Column(name = "execution_result", columnDefinition = "TEXT")
val executionResult: String? = null,
@Column(name = "error_message", columnDefinition = "TEXT")
val errorMessage: String? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -231,26 +231,7 @@ enum class ErrorCode(
SERVER_ORDER_TRACKING_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"),
SERVER_ORDER_TRACKING_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_failed"),
SERVER_ORDER_TRACKING_SELL_FAILED(5903, "处理卖出订单失败", "error.server.order_tracking_sell_failed"),
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed"),
// 回测服务错误 (4601-4699)
BACKTEST_TASK_NOT_FOUND(4601, "回测任务不存在", "error.backtest.task_not_found"),
BACKTEST_LEADER_NOT_FOUND(4602, "Leader不存在", "error.backtest.leader_not_found"),
BACKTEST_DAYS_INVALID(4603, "回测天数超出限制", "error.backtest.days_invalid"),
BACKTEST_INITIAL_BALANCE_INVALID(4604, "初始金额无效", "error.backtest.initial_balance_invalid"),
BACKTEST_TASK_RUNNING(4605, "回测任务正在运行,无法删除", "error.backtest.task_running"),
BACKTEST_TASK_NOT_COMPLETED(4606, "仅支持对已完成的回测任务重新测试", "error.backtest.task_not_completed"),
SERVER_BACKTEST_CREATE_FAILED(5603, "创建回测任务失败", "error.server.backtest_create_failed"),
SERVER_BACKTEST_UPDATE_FAILED(5604, "更新回测任务失败", "error.server.backtest_update_failed"),
SERVER_BACKTEST_DELETE_FAILED(5605, "删除回测任务失败", "error.server.backtest_delete_failed"),
SERVER_BACKTEST_LIST_FETCH_FAILED(5606, "查询回测列表失败", "error.server.backtest_list_fetch_failed"),
SERVER_BACKTEST_DETAIL_FETCH_FAILED(5607, "查询回测详情失败", "error.server.backtest_detail_fetch_failed"),
SERVER_BACKTEST_TRADES_FETCH_FAILED(5608, "查询回测交易记录失败", "error.server.backtest_trades_fetch_failed"),
SERVER_BACKTEST_EXECUTE_FAILED(5609, "回测执行失败", "error.server.backtest_execute_failed"),
SERVER_BACKTEST_HISTORICAL_DATA_FETCH_FAILED(5610, "历史数据获取失败", "error.server.backtest_historical_data_fetch_failed"),
SERVER_BACKTEST_STOP_FAILED(5611, "停止回测任务失败", "error.server.backtest_stop_failed"),
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed");
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed");
companion object {
/**
@@ -0,0 +1,66 @@
package com.wrbug.polymarketbot.enums
/**
* Polymarket 体育项目 Tag ID 枚举
* 用于标识不同体育项目在 Polymarket 中的 tag ID
*/
enum class SportsTagId(val tagId: String, val displayName: String) {
/**
* 美国职业篮球联赛
*/
NBA("745", "NBA"),
/**
* 美国职业棒球大联盟
*/
MLB("100381", "MLB"),
/**
* 美国国家橄榄球联盟
*/
NFL("450", "NFL"),
/**
* 美国大学橄榄球
*/
CFB("100351", "CFB"),
/**
* 美国国家冰球联盟
*/
NHL("899", "NHL"),
/**
* 游戏/电子竞技
*/
GAMES("100639", "GAMES"),
/**
* 美国大学篮球
*/
CBB("101178", "CBB");
companion object {
/**
* 根据 tag ID 查找枚举
*/
fun fromTagId(tagId: String): SportsTagId? {
return values().find { it.tagId == tagId }
}
/**
* 根据显示名称查找枚举
*/
fun fromDisplayName(displayName: String): SportsTagId? {
return values().find { it.displayName.equals(displayName, ignoreCase = true) }
}
/**
* 获取所有 tag IDs 列表
*/
fun getAllTagIds(): List<String> {
return values().map { it.tagId }
}
}
}
@@ -1,51 +0,0 @@
package com.wrbug.polymarketbot.enums
/**
* 钱包类型枚举
*/
enum class WalletType(val value: String, val description: String) {
/**
* Magic 钱包邮箱/OAuth 登录
* 使用 PROXY 代理合约通过 Builder Relayer 执行 Gasless 交易
*/
MAGIC("magic", "Magic(邮箱/OAuth登录)"),
/**
* Safe 钱包MetaMask Web3 钱包
* 使用 Gnosis Safe 代理合约支持 Builder Relayer Gasless 或手动交易
*/
SAFE("safe", "SafeWeb3钱包)");
companion object {
/**
* 从字符串值解析钱包类型不区分大小写
*/
fun fromString(value: String?): WalletType {
if (value.isNullOrBlank()) {
return SAFE // 默认返回 SAFE
}
return values().find { it.value.equals(value, ignoreCase = true) }
?: throw IllegalArgumentException("未知的钱包类型: $value")
}
/**
* 安全地从字符串值解析钱包类型不区分大小写解析失败返回默认值
*/
fun fromStringOrDefault(value: String?, default: WalletType = SAFE): WalletType {
if (value.isNullOrBlank()) {
return default
}
return values().find { it.value.equals(value, ignoreCase = true) } ?: default
}
/**
* 检查字符串是否为有效的钱包类型
*/
fun isValid(value: String?): Boolean {
if (value.isNullOrBlank()) {
return false
}
return values().any { it.value.equals(value, ignoreCase = true) }
}
}
}
@@ -29,10 +29,5 @@ interface AccountRepository : JpaRepository<Account, Long> {
* 检查钱包地址是否存在
*/
fun existsByWalletAddress(walletAddress: String): Boolean
/**
* 检查代理地址是否存在
*/
fun existsByProxyAddress(proxyAddress: String): Boolean
}
@@ -1,63 +0,0 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.BacktestTask
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
/**
* 回测任务Repository
*/
@Repository
interface BacktestTaskRepository : JpaRepository<BacktestTask, Long> {
/**
* 根据 Leader ID 查询回测任务
*/
fun findByLeaderId(leaderId: Long): List<BacktestTask>
/**
* 根据状态查询回测任务
*/
fun findByStatus(status: String): List<BacktestTask>
/**
* 根据 Leader ID 和状态查询回测任务
*/
fun findByLeaderIdAndStatus(leaderId: Long, status: String): List<BacktestTask>
/**
* 根据 Leader ID收益率排序查询
*/
@Query("SELECT t FROM BacktestTask t WHERE t.leaderId = :leaderId AND t.status = :status ORDER BY t.profitRate DESC")
fun findByLeaderIdAndStatusOrderByProfitRateDesc(leaderId: Long, status: String): List<BacktestTask>
/**
* 根据状态和创建时间倒序查询
*/
@Query("SELECT t FROM BacktestTask t WHERE t.status = :status ORDER BY t.createdAt DESC")
fun findByStatusOrderByCreatedAtDesc(status: String): List<BacktestTask>
/**
* 更新回测任务状态
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.status = :status, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateStatus(id: Long, status: String, updatedAt: Long = System.currentTimeMillis())
/**
* 更新回测任务状态和错误信息
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.status = :status, t.errorMessage = :errorMessage, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateStatusAndError(id: Long, status: String, errorMessage: String?, updatedAt: Long = System.currentTimeMillis())
/**
* 更新回测任务进度
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.progress = :progress, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateProgress(id: Long, progress: Int, updatedAt: Long = System.currentTimeMillis())
}
@@ -1,38 +0,0 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.BacktestTrade
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
/**
* 回测交易记录Repository
*/
@Repository
interface BacktestTradeRepository : JpaRepository<BacktestTrade, Long> {
/**
* 根据回测任务ID查询所有交易记录
*/
fun findByBacktestTaskIdOrderByTradeTime(backtestTaskId: Long): List<BacktestTrade>
/**
* 根据回测任务ID分页查询交易记录
*/
@Query("SELECT t FROM BacktestTrade t WHERE t.backtestTaskId = :backtestTaskId ORDER BY t.tradeTime")
fun findByBacktestTaskId(
backtestTaskId: Long,
pageable: org.springframework.data.domain.Pageable
): org.springframework.data.domain.Page<BacktestTrade>
/**
* 根据回测任务ID统计交易数量
*/
fun countByBacktestTaskId(backtestTaskId: Long): Long
/**
* 删除回测任务的所有交易记录由级联删除处理
*/
fun deleteByBacktestTaskId(backtestTaskId: Long)
}
@@ -61,42 +61,5 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
*/
@Query("SELECT t FROM CopyOrderTracking t WHERE t.createdAt <= :beforeTime")
fun findByCreatedAtBefore(beforeTime: Long): List<CopyOrderTracking>
/**
* 查询指定时间之前创建且状态不为指定状态的订单
*/
fun findByCreatedAtBeforeAndStatusNot(beforeTime: Long, status: String): List<CopyOrderTracking>
/**
* 查询指定跟单配置下的活跃仓位数量
* 活跃仓位定义为 remainingQuantity > 0 的不同 (marketId, outcomeIndex) 组合
*/
@Query("SELECT COUNT(DISTINCT CONCAT(t.marketId, '_', COALESCE(t.outcomeIndex, -1))) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.remainingQuantity > 0")
fun countActivePositions(copyTradingId: Long): Int
/**
* 计算指定跟单配置市场和方向下的当前持仓总价值 (成本价计算)
* 按市场+方向outcomeIndex分别统计
*/
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0")
fun sumCurrentPositionValueByMarketAndOutcomeIndex(copyTradingId: Long, marketId: String, outcomeIndex: Int): BigDecimal?
/**
* 查询指定跟单配置下创建时间超过指定时间点的未匹配订单FIFO顺序
* 用于避免刚创建的订单被误判为已卖出
*
* @param copyTradingId 跟单配置ID
* @param marketId 市场ID
* @param outcomeIndex 结果索引
* @param thresholdTime 时间阈值毫秒时间戳只查询创建时间小于该值的订单
* @return 未匹配订单列表按创建时间升序排列
*/
@Query("SELECT t FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0 AND t.createdAt < :thresholdTime ORDER BY t.createdAt ASC")
fun findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
copyTradingId: Long,
marketId: String,
outcomeIndex: Int,
thresholdTime: Long
): List<CopyOrderTracking>
}
@@ -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
): List<CopyTrading>
): CopyTrading?
/**
* 查找所有启用的跟单
@@ -1,12 +0,0 @@
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>
}
@@ -0,0 +1,27 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaGame
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.LocalDate
@Repository
interface NbaGameRepository : JpaRepository<NbaGame, Long> {
fun findByNbaGameId(nbaGameId: String): NbaGame?
fun findByGameDate(gameDate: LocalDate): List<NbaGame>
fun findByGameDateBetween(startDate: LocalDate, endDate: LocalDate): List<NbaGame>
fun findByGameStatus(gameStatus: String): List<NbaGame>
fun findByHomeTeamAndAwayTeamAndGameDate(homeTeam: String, awayTeam: String, gameDate: LocalDate): NbaGame?
fun findByPolymarketMarketId(polymarketMarketId: String): NbaGame?
/**
* 查询最新的比赛按创建时间倒序
*/
fun findFirstByOrderByCreatedAtDesc(): NbaGame?
/**
* 根据创建时间查询比赛
*/
fun findByCreatedAtGreaterThan(createdAt: Long): List<NbaGame>
}
@@ -0,0 +1,15 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaMarket
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface NbaMarketRepository : JpaRepository<NbaMarket, Long> {
fun findByConditionId(conditionId: String): NbaMarket?
fun findByPolymarketMarketId(polymarketMarketId: String): NbaMarket?
fun findByActiveAndClosed(active: Boolean, closed: Boolean): List<NbaMarket>
fun findByCategory(category: String): List<NbaMarket>
fun findByActive(active: Boolean): List<NbaMarket>
}
@@ -0,0 +1,14 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaQuantitativeStrategy
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface NbaQuantitativeStrategyRepository : JpaRepository<NbaQuantitativeStrategy, Long> {
fun findByAccountId(accountId: Long): List<NbaQuantitativeStrategy>
fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List<NbaQuantitativeStrategy>
fun findByEnabled(enabled: Boolean): List<NbaQuantitativeStrategy>
fun findByStrategyName(strategyName: String): NbaQuantitativeStrategy?
}
@@ -0,0 +1,14 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaStrategyStatistics
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.LocalDate
@Repository
interface NbaStrategyStatisticsRepository : JpaRepository<NbaStrategyStatistics, Long> {
fun findByStrategyId(strategyId: Long): List<NbaStrategyStatistics>
fun findByStrategyIdAndStatDate(strategyId: Long, statDate: LocalDate): NbaStrategyStatistics?
fun findByStrategyIdAndStatDateBetween(strategyId: Long, startDate: LocalDate, endDate: LocalDate): List<NbaStrategyStatistics>
}
@@ -0,0 +1,18 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaTradingSignal
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.Instant
@Repository
interface NbaTradingSignalRepository : JpaRepository<NbaTradingSignal, Long> {
fun findByStrategyId(strategyId: Long): List<NbaTradingSignal>
fun findByGameId(gameId: Long): List<NbaTradingSignal>
fun findByMarketId(marketId: Long): List<NbaTradingSignal>
fun findBySignalType(signalType: String): List<NbaTradingSignal>
fun findBySignalStatus(signalStatus: String): List<NbaTradingSignal>
fun findByStrategyIdAndSignalType(strategyId: Long, signalType: String): List<NbaTradingSignal>
fun findByStrategyIdAndCreatedAtBetween(strategyId: Long, startTime: Long, endTime: Long): List<NbaTradingSignal>
}
@@ -3,17 +3,13 @@ package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.getEventSlug
import com.wrbug.polymarketbot.service.common.PolymarketClobService
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
@@ -40,7 +36,6 @@ 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
@@ -68,6 +63,11 @@ class AccountService(
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 检查地址是否已存在
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
}
// 3. 验证私钥和地址的对应关系
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
@@ -101,8 +101,7 @@ class AccountService(
// 5. 获取代理地址(必须成功,否则导入失败)
// 根据用户选择的钱包类型计算代理地址
val proxyAddress = runBlocking {
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, walletTypeEnum)
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
if (proxyResult.isSuccess) {
val address = proxyResult.getOrNull()
if (address != null) {
@@ -118,31 +117,25 @@ class AccountService(
}
}
// 6. 按代理地址去重:该代理地址已存在则不允许重复导入
if (accountRepository.existsByProxyAddress(proxyAddress)) {
return Result.failure(IllegalArgumentException("ACCOUNT_ALREADY_EXISTS"))
}
// 7. 加密敏感信息
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
// 8. 生成账户名称(如果未提供,使用钱包地址后位)
val accountName = if (request.accountName.isNullOrBlank()) {
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
val typeLabel = walletTypeEnum.name.uppercase()
val proxyWithoutPrefix = if (proxyAddress.startsWith("0x") || proxyAddress.startsWith("0X")) {
proxyAddress.substring(2)
val walletAddress = request.walletAddress.trim()
// 取地址后四位(去掉 0x 前缀后取后四位)
val addressWithoutPrefix = if (walletAddress.startsWith("0x") || walletAddress.startsWith("0X")) {
walletAddress.substring(2)
} else {
proxyAddress
walletAddress
}
val suffix = if (proxyWithoutPrefix.length >= 4) {
proxyWithoutPrefix.substring(proxyWithoutPrefix.length - 4).uppercase()
if (addressWithoutPrefix.length >= 4) {
addressWithoutPrefix.substring(addressWithoutPrefix.length - 4).uppercase()
} else {
proxyWithoutPrefix.uppercase()
addressWithoutPrefix.uppercase()
}
"$typeLabel-$suffix"
} else {
request.accountName.trim()
}
@@ -175,192 +168,6 @@ class AccountService(
}
}
/**
* 检查代理地址选项用于账户导入前选择代理类型
* 私钥导入返回 Magic Safe 两个选项
* 助记词导入仅返回 Safe 选项
*/
suspend fun checkProxyOptions(request: CheckProxyOptionsRequest): Result<CheckProxyOptionsResponse> {
return try {
// 1. 验证钱包地址格式
if (!isValidWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 验证至少提供了私钥或助记词之一
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("必须提供私钥或助记词"))
}
val options = mutableListOf<ProxyOptionDto>()
// 3. 判断导入类型
val isPrivateKeyImport = !request.privateKey.isNullOrBlank()
if (isPrivateKeyImport) {
// 私钥导入:并行获取 Magic 和 Safe 代理地址及资产
coroutineScope {
val magicDeferred = async {
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.MAGIC).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
ProxyOptionDto(
walletType = WalletType.MAGIC.value,
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
} else {
ProxyOptionDto(
walletType = "magic",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Magic 代理地址失败"
)
}
} catch (e: Exception) {
logger.warn("获取 Magic 代理地址或资产失败: ${e.message}", e)
ProxyOptionDto(
walletType = "magic",
proxyAddress = blockchainService.calculateMagicProxyAddress(request.walletAddress),
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
}
}
val safeDeferred = async {
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
ProxyOptionDto(
walletType = WalletType.SAFE.value,
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
} else {
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Safe 代理地址失败"
)
}
} catch (e: Exception) {
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
}
}
val magicOption = magicDeferred.await()
val safeOption = safeDeferred.await()
// Safe 在前,Magic 在后
options.add(safeOption)
options.add(magicOption)
}
} else {
// 助记词导入:仅获取 Safe 代理地址及资产
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
)
} else {
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Safe 代理地址失败"
)
)
}
} catch (e: Exception) {
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
)
}
}
Result.success(CheckProxyOptionsResponse(options = options))
} catch (e: Exception) {
logger.error("检查代理地址选项失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 更新账户信息
*/
@@ -395,6 +202,85 @@ class AccountService(
}
}
/**
* 刷新账户的代理地址
* 使用最新的代理地址计算逻辑支持 Magic Safe 两种类型
*/
@Transactional
fun refreshProxyAddress(accountId: Long): Result<AccountDto> {
return try {
val account = accountRepository.findById(accountId)
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 重新获取代理地址(使用保存的钱包类型)
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(account.walletAddress, account.walletType)
if (proxyResult.isSuccess) {
proxyResult.getOrNull()
?: throw IllegalStateException("获取代理地址返回空值")
} else {
val error = proxyResult.exceptionOrNull()
throw IllegalStateException("获取代理地址失败: ${error?.message}")
}
}
// 更新账户
val updated = account.copy(
proxyAddress = proxyAddress,
updatedAt = System.currentTimeMillis()
)
val saved = accountRepository.save(updated)
logger.info("刷新代理地址成功: accountId=${accountId}, oldProxy=${account.proxyAddress}, newProxy=${proxyAddress}")
Result.success(toDto(saved))
} catch (e: Exception) {
logger.error("刷新代理地址失败: accountId=${accountId}", e)
Result.failure(e)
}
}
/**
* 刷新所有账户的代理地址
*/
@Transactional
fun refreshAllProxyAddresses(): Result<List<AccountDto>> {
return try {
val accounts = accountRepository.findAll()
val updatedAccounts = mutableListOf<AccountDto>()
accounts.forEach { account ->
try {
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(account.walletAddress, account.walletType)
if (proxyResult.isSuccess) {
proxyResult.getOrNull()
} else {
null
}
}
if (proxyAddress != null && proxyAddress != account.proxyAddress) {
val updated = account.copy(
proxyAddress = proxyAddress,
updatedAt = System.currentTimeMillis()
)
val saved = accountRepository.save(updated)
logger.info("刷新代理地址成功: accountId=${account.id}, oldProxy=${account.proxyAddress}, newProxy=${proxyAddress}")
updatedAccounts.add(toDto(saved))
}
} catch (e: Exception) {
logger.warn("刷新账户 ${account.id} 代理地址失败: ${e.message}")
}
}
logger.info("批量刷新代理地址完成: 更新了 ${updatedAccounts.size} 个账户")
Result.success(updatedAccounts)
} catch (e: Exception) {
logger.error("批量刷新代理地址失败", e)
Result.failure(e)
}
}
/**
* 删除账户
*/
@@ -421,12 +307,11 @@ class AccountService(
/**
* 查询账户列表
* 列表接口只返回基本信息不查询统计信息统计信息只在详情接口中查询
*/
fun getAccountList(): Result<AccountListResponse> {
return try {
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
val accountDtos = accounts.map { toBasicDto(it) }
val accountDtos = accounts.map { toDto(it) }
Result.success(
AccountListResponse(
@@ -468,7 +353,7 @@ class AccountService(
if (accountId == null) {
return Result.failure(IllegalArgumentException("账户ID不能为空"))
}
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
@@ -478,19 +363,68 @@ class AccountService(
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
}
// 使用通用方法查询余额
// 查询 USDC 余额和持仓信息
val balanceResult = runBlocking {
blockchainService.getWalletBalance(account.proxyAddress)
try {
// 查询持仓信息(用于返回持仓列表)
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
val positionsResult = blockchainService.getPositions(account.proxyAddress)
val positions = if (positionsResult.isSuccess) {
positionsResult.getOrNull()?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 使用 /value 接口获取仓位总价值(而不是累加)
val positionBalanceResult = blockchainService.getTotalValue(account.proxyAddress)
val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
}
// 查询可用余额(通过 RPC 查询 USDC 余额)
// 必须使用代理地址查询
val availableBalanceResult = blockchainService.getUsdcBalance(
walletAddress = account.walletAddress,
proxyAddress = account.proxyAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
AccountBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(),
positions = positions
)
} catch (e: Exception) {
logger.error("查询余额失败: ${e.message}", e)
throw e
}
}
balanceResult.map { walletBalance: WalletBalanceResponse ->
AccountBalanceResponse(
availableBalance = walletBalance.availableBalance,
positionBalance = walletBalance.positionBalance,
totalBalance = walletBalance.totalBalance,
positions = walletBalance.positions
)
}
Result.success(balanceResult)
} catch (e: Exception) {
logger.error("查询账户余额失败", e)
Result.failure(e)
@@ -498,30 +432,7 @@ class AccountService(
}
/**
* 转换为基础 DTO列表使用不包含统计信息
* 列表接口只返回基本信息不查询统计信息以提高性能
*/
private fun toBasicDto(account: Account): AccountDto {
return AccountDto(
id = account.id!!,
walletAddress = account.walletAddress,
proxyAddress = account.proxyAddress,
accountName = account.accountName,
isEnabled = account.isEnabled,
walletType = account.walletType,
apiKeyConfigured = account.apiKey != null,
apiSecretConfigured = account.apiSecret != null,
apiPassphraseConfigured = account.apiPassphrase != null,
totalOrders = null,
totalPnl = null,
activeOrders = null,
completedOrders = null,
positionCount = null
)
}
/**
* 转换为完整 DTO详情使用包含交易统计数据
* 转换为 DTO
* 包含交易统计数据总订单数总盈亏活跃订单数已完成订单数持仓数量
*/
private fun toDto(account: Account): AccountDto {
@@ -800,8 +711,7 @@ class AccountService(
proxyAddress = account.proxyAddress,
marketId = pos.conditionId ?: "",
marketTitle = pos.title ?: "",
marketSlug = pos.slug ?: "", // 显示用的 slug
eventSlug = pos.eventSlug, // 跳转用的 slug(从 events[0].slug 获取)
marketSlug = pos.slug ?: "",
marketIcon = pos.icon, // 市场图标
side = pos.outcome ?: "",
outcomeIndex = pos.outcomeIndex, // 添加 outcomeIndex
@@ -1008,16 +918,7 @@ class AccountService(
// 7. 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType
// 11. 创建并签名订单(使用计算后的卖出数量
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -1026,9 +927,9 @@ class AccountService(
side = "SELL",
price = sellPrice,
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
signatureType = 2, // Browser Wallet(与正确订单数据一致)
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
feeRateBps = "0",
expiration = expiration
)
} catch (e: Exception) {
@@ -1078,20 +979,31 @@ class AccountService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val market = marketService.getMarket(request.marketId)
val marketTitle = market?.title ?: request.marketId
val marketSlug = market?.eventSlug // 跳转用的 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
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
// 使用当前时间作为订单创建时间
val orderTime = System.currentTimeMillis()
telegramNotificationService?.sendOrderSuccessNotification(
orderId = orderId,
marketTitle = marketTitle,
@@ -1107,8 +1019,7 @@ class AccountService(
apiSecret = try { cryptoUtils.decrypt(account.apiSecret!!) } catch (e: Exception) { null },
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
walletAddressForApi = account.walletAddress,
locale = locale,
orderTime = orderTime // 使用订单创建时间
locale = locale
)
} catch (e: Exception) {
logger.warn("发送订单成功通知失败: ${e.message}", e)
@@ -1136,17 +1047,31 @@ class AccountService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val market = marketService.getMarket(request.marketId)
val marketTitle = market?.title ?: request.marketId
val marketSlug = market?.eventSlug // 跳转用的 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
// 获取当前语言设置(从 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,
@@ -1180,20 +1105,34 @@ class AccountService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val market = marketService.getMarket(request.marketId)
val marketTitle = market?.title ?: request.marketId
val marketSlug = market?.eventSlug // 跳转用的 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
// 获取当前语言设置(从 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,
@@ -1379,6 +1318,13 @@ class AccountService(
*/
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
return try {
// 检查 Builder API Key 是否已配置
if (!relayClientService.isBuilderApiKeyConfigured()) {
return Result.failure(
IllegalStateException("Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。")
)
}
if (request.positions.isEmpty()) {
return Result.failure(IllegalArgumentException("赎回仓位列表不能为空"))
}
@@ -1400,17 +1346,7 @@ class AccountService(
accounts[accountId] = account
}
// 4. 若涉及 Magic 账户,必须已配置 Builder API Key(提前判断,避免执行到深层再报错
val hasMagicAccount = accounts.values.any {
WalletType.fromStringOrDefault(it.walletType, WalletType.SAFE) == WalletType.MAGIC
}
if (hasMagicAccount && !relayClientService.isBuilderApiKeyConfigured()) {
return Result.failure(
IllegalStateException("Builder API Key 未配置,无法执行 Magic 账户赎回(Gasless)。请前往系统设置页面配置 Builder API Key。")
)
}
// 5. 验证并收集要赎回的仓位信息(按账户分组)
// 4. 验证并收集要赎回的仓位信息(按账户分组
val accountRedeemData = mutableMapOf<Long, MutableList<Pair<AccountPositionDto, BigInteger>>>()
val accountRedeemedInfo =
mutableMapOf<Long, MutableList<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>>()
@@ -1453,7 +1389,7 @@ class AccountService(
accountRedeemedInfo[accountId] = accountInfo
}
// 6. 对每个账户执行赎回Safe 与 Magic 均支持,Magic 通过 Builder Relayer PROXY Gasless 执行)
// 5. 对每个账户执行赎回
val accountTransactions = mutableListOf<com.wrbug.polymarketbot.dto.AccountRedeemTransaction>()
var totalRedeemedValue = BigDecimal.ZERO
@@ -1473,13 +1409,11 @@ class AccountService(
val decryptedPrivateKey = decryptPrivateKey(account)
// 调用区块链服务赎回仓位
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
val redeemResult = blockchainService.redeemPositions(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets,
walletType = walletTypeEnum
indexSets = indexSets
)
redeemResult.fold(
@@ -1510,7 +1444,7 @@ class AccountService(
)
}
// 7. 发送赎回推送通知(异步,不阻塞)
// 6. 发送赎回推送通知(异步,不阻塞)
notificationScope.launch {
try {
// 获取当前语言设置
@@ -62,19 +62,6 @@ 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()
@@ -86,7 +73,6 @@ class PositionCheckService(
logger.info("PositionCheckService 初始化,订阅仓位轮训事件")
startSubscription()
startCacheCleanup()
startPendingPositionCheckTask()
}
/**
@@ -146,121 +132,6 @@ 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小时的记录
*/
@@ -292,16 +163,8 @@ class PositionCheckService(
notifiedConfigs.remove(key)
}
// 清理过期的待检查仓位记录(超过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}")
if (expiredPositions.isNotEmpty() || expiredProcessed.isNotEmpty() || expiredConfigs.isNotEmpty()) {
logger.debug("清理过期缓存: positions=${expiredPositions.size}, processed=${expiredProcessed.size}, configs=${expiredConfigs.size}")
}
}
@@ -430,20 +293,27 @@ class PositionCheckService(
processedRedeemablePositions[positionKey] = now
}
// 赎回成功后,按每个跟单配置分别查找未卖出订单并更新状态
// 同一账户同一市场可能同时跟多个 Leader,需按 copyTradingId 分别生成自动卖出记录(如 leader1 对应 20 shareleader2 对应 16 share
// 赎回成功后,再查找订单并更新订单状态
for (position in positionsToRedeem) {
if (position.outcomeIndex == null) {
continue
}
// 查找相同仓位的未卖出订单(remaining_quantity > 0
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
for (copyTrading in copyTradings) {
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id!!,
position.marketId,
position.outcomeIndex
)
if (orders.isNotEmpty()) {
updateOrdersAsSoldAfterRedeem(orders, position, copyTrading.id!!)
if (position.outcomeIndex != null) {
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id!!,
position.marketId,
position.outcomeIndex
)
unmatchedOrders.addAll(orders)
}
}
// 如果有未卖出订单,更新订单状态
if (unmatchedOrders.isNotEmpty()) {
// 从订单中获取 copyTradingId(所有订单应该有相同的 copyTradingId
val copyTradingId = unmatchedOrders.firstOrNull()?.copyTradingId
if (copyTradingId != null) {
updateOrdersAsSoldAfterRedeem(unmatchedOrders, position, copyTradingId)
}
}
}
@@ -501,100 +371,51 @@ class PositionCheckService(
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
if (position == null) {
// 仓位不存在,使用延迟检测机制
// 先查询创建时间超过2分钟的未匹配订单(SQL层过滤,避免刚创建的订单被误判)
// 仓位不存在,检查订单创建时间
// 只有当订单创建时间超过2分钟时,才认为仓位被出售了
// 这样可以避免刚创建的订单因为API延迟而被误判为已卖出
val now = System.currentTimeMillis()
val thresholdTime = now - 120000 // 2分钟 = 120000毫秒
val ordersToCheck = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
copyTradingId = copyTrading.id!!,
marketId = marketId,
outcomeIndex = outcomeIndex,
thresholdTime = thresholdTime
)
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")
}
val ordersToMarkAsSold = orders.filter { order ->
val orderAge = now - order.createdAt
orderAge > 120000 // 2分钟 = 120000毫秒
}
if (ordersToMarkAsSold.isNotEmpty()) {
// 有订单创建时间超过2分钟,认为仓位已被出售
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSold(ordersToMarkAsSold, currentPrice, copyTrading.id, marketId, outcomeIndex)
logger.debug("仓位不存在且订单创建时间超过2分钟,标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${ordersToMarkAsSold.size}")
} else {
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
logger.debug("仓位不存在但无符合条件的订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, thresholdTime=$thresholdTime, positionKey=$positionKey")
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}")
}
} 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)更新状态
// 先查询创建时间超过2分钟的未匹配订单(SQL层过滤,避免刚创建的订单被误判)
val now = System.currentTimeMillis()
val thresholdTime = now - 120000 // 2分钟 = 120000毫秒
val validOrders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
copyTradingId = copyTrading.id!!,
marketId = marketId,
outcomeIndex = outcomeIndex,
thresholdTime = thresholdTime
)
// 如果没有符合条件的订单,跳过处理
if (validOrders.isEmpty()) {
logger.debug("仓位存在但无符合条件的订单(创建时间不足2分钟),暂不进行FIFO匹配: marketId=$marketId, outcomeIndex=$outcomeIndex, thresholdTime=$thresholdTime")
continue
}
// 计算逻辑:
// 1. 总订单数量 = 所有符合条件的未卖出订单的剩余数量总和
// 1. 总订单数量 = 所有未卖出订单的剩余数量总和
// 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出)
// 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态
// 4. 如果已成交数量 > 0,按FIFO顺序匹配订单
val positionQuantity = position.quantity.toSafeBigDecimal()
// 计算总订单数量(只计算符合条件的订单)
val totalOrderQuantity = validOrders.fold(BigDecimal.ZERO) { sum, order ->
// 计算总订单数量
val totalOrderQuantity = orders.fold(BigDecimal.ZERO) { sum, order ->
sum.add(order.remainingQuantity.toSafeBigDecimal())
}
// 计算已成交数量
val soldQuantity = totalOrderQuantity.subtract(positionQuantity)
// 如果已成交数量 <= 0,说明订单还没有卖出,不修改订单状态
if (soldQuantity <= BigDecimal.ZERO) {
logger.debug("仓位数量 >= 订单数量总和,订单尚未卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, positionQuantity=$positionQuantity, totalOrderQuantity=$totalOrderQuantity")
continue
}
// 如果已成交数量 > 0,按FIFO顺序匹配订单(只匹配符合条件的订单)
try {
// 如果已成交数量 > 0,按FIFO顺序匹配订单
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSoldByFIFO(validOrders, soldQuantity, currentPrice,
updateOrdersAsSoldByFIFO(orders, soldQuantity, currentPrice,
copyTrading.id, marketId, outcomeIndex)
} catch (e: Exception) {
logger.warn("无法获取市场价格,跳过FIFO匹配: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
// 无法获取价格时,跳过该市场的处理,等待下次检查时再试
continue
}
}
}
}
@@ -21,29 +21,29 @@ import java.util.concurrent.CopyOnWriteArrayList
class PositionPollingService(
private val accountService: AccountService
) {
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
@Value("\${position.polling.interval:2000}")
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
// 订阅者列表(支持多个订阅者)
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
// 最新仓位数据(用于丢弃机制)
@Volatile
private var latestPositions: PositionListResponse? = null
// 协程作用域和任务
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var pollingJob: Job? = null
// 事件分发协程(使用专门的线程,避免阻塞轮训)
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 同步锁,确保轮询任务的启动和停止是线程安全的
private val lock = Any()
/**
* 初始化服务后端启动时直接启动轮训
*/
@@ -52,7 +52,7 @@ class PositionPollingService(
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
startPolling()
}
/**
* 清理资源
*/
@@ -66,7 +66,7 @@ class PositionPollingService(
scope.cancel()
eventDispatcherScope.cancel()
}
/**
* 订阅仓位事件
* @param callback 回调函数接收最新的仓位数据
@@ -78,7 +78,7 @@ class PositionPollingService(
latestPositions?.let { callback(it) }
}
}
/**
* 取消订阅仓位事件
*/
@@ -87,7 +87,7 @@ class PositionPollingService(
subscribers.remove(callback)
}
}
/**
* 启动轮训任务
*/
@@ -95,7 +95,7 @@ class PositionPollingService(
synchronized(lock) {
// 如果已经有轮训任务在运行,先取消
pollingJob?.cancel()
// 启动新的轮训任务
pollingJob = scope.launch {
while (isActive) {
@@ -109,7 +109,7 @@ class PositionPollingService(
}
}
}
/**
* 轮训仓位数据并发布事件
* 使用专门的线程分发事件避免阻塞轮训
@@ -123,7 +123,7 @@ class PositionPollingService(
if (positions != null) {
// 更新最新数据(丢弃旧数据,只保留最新的)
latestPositions = positions
// 在专门的线程中分发事件,避免阻塞轮训
eventDispatcherScope.launch {
try {
@@ -131,7 +131,7 @@ class PositionPollingService(
val currentSubscribers = synchronized(lock) {
subscribers.toList() // 复制列表,避免并发修改
}
currentSubscribers.forEach { callback ->
try {
callback(positions)
@@ -139,6 +139,8 @@ class PositionPollingService(
logger.error("通知订阅者失败: ${e.message}", e)
}
}
logger.debug("发布仓位数据事件: currentPositions=${positions.currentPositions.size}, historyPositions=${positions.historyPositions.size}, subscribers=${currentSubscribers.size}")
} catch (e: Exception) {
logger.error("分发仓位数据事件失败: ${e.message}", e)
}
@@ -1,137 +0,0 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.UserActivityResponse
import com.wrbug.polymarketbot.dto.TradeData
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.delay
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
/**
* 基于 start 游标的一批历史交易结果
* @param trades 本批交易列表已按时间升序
* @param nextCursorSeconds 下一页游标API start 参数秒级若本批不足 limit 条则为 null 表示最后一页
*/
data class LeaderTradesBatchResult(
val trades: List<TradeData>,
val nextCursorSeconds: Long?
)
/**
* 回测数据服务
* 直接从 Polymarket Data API 获取 Leader 历史交易使用 start 游标分页避免 offset 过大报错
*/
@Service
class BacktestDataService(
private val leaderRepository: LeaderRepository,
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(BacktestDataService::class.java)
/**
* start 游标获取一批 Leader 历史交易
* 规则limit 固定为 500若返回 500 条则取本批最大时间戳作为下一页 start不加 1同一秒可能多笔订单由下游按 tradeId 去重不足 500 则为最后一页
*
* @param leaderId Leader ID
* @param startTime 回测开始时间毫秒
* @param endTime 回测结束时间毫秒
* @param cursorStartSeconds 本页游标API start首次传 startTime/1000
* @param limit 每批条数建议 500
* @return 本批交易与下一页游标null 表示没有下一页
*/
suspend fun getLeaderHistoricalTradesBatch(
leaderId: Long,
startTime: Long,
endTime: Long,
cursorStartSeconds: Long,
limit: Int
): LeaderTradesBatchResult {
logger.info("获取 Leader 历史交易批次: leaderId=$leaderId, cursorStart=$cursorStartSeconds, limit=$limit")
val leader = leaderRepository.findById(leaderId).orElse(null)
?: throw IllegalArgumentException("Leader 不存在: $leaderId")
val dataApi = retrofitFactory.createDataApi()
val endSeconds = endTime / 1000
val maxRetries = 5
val retryDelay = 1000L
var lastException: Exception? = null
for (attempt in 1..maxRetries) {
try {
val response = dataApi.getUserActivity(
user = leader.leaderAddress,
type = listOf("TRADE"),
start = cursorStartSeconds,
end = endSeconds,
limit = limit,
offset = null,
sortBy = "TIMESTAMP",
sortDirection = "ASC"
)
if (!response.isSuccessful || response.body() == null) {
throw Exception("从 Data API 获取用户活动失败: code=${response.code()}, message=${response.message()}")
}
val activities = response.body()!!
logger.info("本批获取 ${activities.size} 条活动(第 $attempt 次尝试)")
val trades = activities.mapNotNull { activity ->
try {
if (activity.type != "TRADE") return@mapNotNull null
if (activity.side == null || activity.price == null || activity.size == null || activity.usdcSize == null) {
logger.warn("活动数据缺少必要字段,跳过: activity=$activity")
return@mapNotNull null
}
val tradeTimestamp = activity.timestamp * 1000
if (tradeTimestamp < startTime || tradeTimestamp > endTime) {
logger.debug("交易时间超出范围,跳过: timestamp=$tradeTimestamp")
return@mapNotNull null
}
TradeData(
tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}",
marketId = activity.conditionId,
marketTitle = activity.title,
marketSlug = activity.slug,
side = activity.side.uppercase(),
outcome = activity.outcome ?: activity.outcomeIndex?.toString() ?: "",
outcomeIndex = activity.outcomeIndex,
price = activity.price.toSafeBigDecimal(),
size = activity.size.toSafeBigDecimal(),
amount = activity.usdcSize.toSafeBigDecimal(),
timestamp = tradeTimestamp
)
} catch (e: Exception) {
logger.warn("转换活动数据失败: activity=$activity, error=${e.message}", e)
null
}
}
// 下一页 start 用本批最大 timestamp(秒),不加 1:同一秒可能有多笔订单,依赖下游按 tradeId 去重
val nextCursorSeconds: Long? = if (trades.size < limit) {
null
} else {
val maxTs = trades.maxOf { it.timestamp }
maxTs / 1000
}
return LeaderTradesBatchResult(trades = trades, nextCursorSeconds = nextCursorSeconds)
} catch (e: Exception) {
lastException = e
logger.warn("$attempt/$maxRetries 次获取批次失败: ${e.message}")
if (attempt < maxRetries) {
logger.info("等待 $retryDelay 毫秒后重试...")
delay(retryDelay)
}
}
}
val errorMsg = "重试 $maxRetries 次后仍然失败,cursorStart=$cursorStartSeconds"
logger.error(errorMsg, lastException)
throw Exception(errorMsg, lastException)
}
}
@@ -1,792 +0,0 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.dto.TradeData
import com.wrbug.polymarketbot.dto.BacktestStatisticsDto
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.entity.BacktestTrade
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.service.common.MarketPriceService
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.max
import kotlin.math.min
@Service
class BacktestExecutionService(
private val backtestTaskRepository: BacktestTaskRepository,
private val backtestTradeRepository: BacktestTradeRepository,
private val backtestDataService: BacktestDataService,
private val marketPriceService: MarketPriceService,
private val marketService: MarketService,
private val copyTradingFilterService: CopyTradingFilterService
) {
private val logger = LoggerFactory.getLogger(BacktestExecutionService::class.java)
/**
* 持仓数据结构
* @param marketEndDate 市场结束时间毫秒用于到期结算判断null 表示未知
*/
data class Position(
val marketId: String,
val outcome: String,
val outcomeIndex: Int?,
var quantity: BigDecimal,
val avgPrice: BigDecimal,
val leaderBuyQuantity: BigDecimal?,
val marketEndDate: Long? = null
)
/**
* 将回测任务转换为虚拟的 CopyTrading 配置用于执行
* 注意回测场景使用历史数据不需要实时跟单的相关配置
*/
private fun taskToCopyTrading(task: BacktestTask): CopyTrading {
return CopyTrading(
id = task.id,
accountId = 0L,
leaderId = task.leaderId,
enabled = true,
copyMode = task.copyMode,
copyRatio = task.copyRatio,
fixedAmount = null,
maxOrderSize = task.maxOrderSize,
minOrderSize = task.minOrderSize,
maxDailyLoss = task.maxDailyLoss,
maxDailyOrders = task.maxDailyOrders,
priceTolerance = BigDecimal.ZERO, // 回测使用历史价格,不需要容忍度
delaySeconds = 0, // 回测按时间线执行,无需延迟
pollIntervalSeconds = 5,
useWebSocket = false,
websocketReconnectInterval = 5000,
websocketMaxRetries = 10,
supportSell = task.supportSell,
minOrderDepth = null, // 回测无实时订单簿数据
maxSpread = null, // 回测无实时价差数据
maxPositionValue = task.maxPositionValue,
minPrice = task.minPrice, // 最低价格
maxPrice = task.maxPrice, // 最高价格
keywordFilterMode = task.keywordFilterMode,
keywords = task.keywords,
configName = null,
pushFailedOrders = false,
pushFilteredOrders = false,
createdAt = task.createdAt,
updatedAt = task.updatedAt
)
}
/**
* 执行回测任务支持分页和恢复
* 自动处理所有页面的数据支持中断恢复
*/
/** 每批请求 API 的条数(基于 start 游标分页,避免 offset 过大) */
private val backtestBatchLimit = 500
@Transactional
suspend fun executeBacktest(task: BacktestTask, page: Int = 1, size: Int = 100) {
try {
logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}, batchLimit=$backtestBatchLimit")
// 1. 更新任务状态为 RUNNING
task.status = "RUNNING"
task.executionStartedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
// 2. 初始化
var currentBalance = task.initialBalance
val positions = mutableMapOf<String, Position>()
val trades = mutableListOf<BacktestTrade>()
val dailyOrderCountCache = mutableMapOf<String, Int>()
val dailyLossCache = mutableMapOf<String, BigDecimal>()
val seenTradeIds = mutableSetOf<String>()
// 3. 回测时间范围:首次执行以当前时间为基准取最近 backtestDays 天;断点续跑保留原 startTime,仅 endTime 延到当前
val endTime = System.currentTimeMillis()
val startTime = if (task.lastProcessedTradeTime == null) {
endTime - (task.backtestDays * 24L * 3600 * 1000)
} else {
task.startTime
}
logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)} (${task.backtestDays} 天), " +
"初始余额: ${task.initialBalance.toPlainString()}")
// 4. 游标分页:恢复时也从 lastProcessedTradeTime 所在秒开始拉(不加 1),与分页规则一致;已处理的通过 timestamp 跳过,不依赖内存 seenTradeIds
var cursorSeconds = if (task.lastProcessedTradeTime != null) {
task.lastProcessedTradeTime!! / 1000
} else {
startTime / 1000
}
val endSeconds = endTime / 1000
val resumeThresholdMs = task.lastProcessedTradeTime ?: 0L
logger.info("开始游标分页:cursorStart=$cursorSeconds(恢复则跳过 timestamp<=${resumeThresholdMs}ms")
var terminateBacktest = false
while (true) {
if (terminateBacktest) {
logger.info("余额已为负或不足,终止回测循环")
break
}
val currentTaskStatus = backtestTaskRepository.findById(task.id!!).orElse(null)
if (currentTaskStatus == null || currentTaskStatus.status != "RUNNING") {
logger.info("回测任务状态已变更: ${currentTaskStatus?.status},停止执行")
break
}
logger.info("正在获取批次数据 cursorStart=$cursorSeconds (${formatTimestamp(cursorSeconds * 1000)}) ...")
val currentPageTrades = mutableListOf<BacktestTrade>()
try {
val batch = backtestDataService.getLeaderHistoricalTradesBatch(
task.leaderId,
startTime,
endTime,
cursorSeconds,
backtestBatchLimit
)
val pageTrades = batch.trades
if (pageTrades.isEmpty()) {
logger.info("本批无数据,所有数据处理完成")
break
}
logger.info("本批获取 ${pageTrades.size} 条交易,是否有下一页: ${batch.nextCursorSeconds != null}")
val countAtBatchStart = task.processedTradeCount
var lastProcessedIndexInPage: Int? = null
var processedInBatch = 0
for (localIndex in pageTrades.indices) {
val leaderTrade = pageTrades[localIndex]
if (leaderTrade.tradeId in seenTradeIds) {
logger.debug("跳过重复交易: ${leaderTrade.tradeId}")
continue
}
if (resumeThresholdMs > 0 && leaderTrade.timestamp <= resumeThresholdMs) {
logger.debug("恢复时跳过已处理时间戳: tradeId=${leaderTrade.tradeId}, timestamp=${leaderTrade.timestamp}")
continue
}
seenTradeIds.add(leaderTrade.tradeId)
val index = countAtBatchStart + processedInBatch
lastProcessedIndexInPage = index
processedInBatch++
// 进度按时间比例:(当前订单时间 - 开始时间) / (结束时间 - 开始时间) * 100,运行中上限 99
val timeRange = endTime - startTime
val progress = if (timeRange > 0) {
val elapsed = (leaderTrade.timestamp - startTime).coerceIn(0L, timeRange)
min(99, ((elapsed * 100) / timeRange).toInt())
} else {
0
}
if (progress > task.progress) {
task.progress = progress
task.processedTradeCount = index + 1
backtestTaskRepository.save(task)
}
try {
// 5.1 实时检查并结算已到期的市场
currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp, currentPageTrades)
// 5.2 检查余额和持仓状态
if (currentBalance <= BigDecimal.ONE) {
logger.info(
if (currentBalance < BigDecimal.ZERO) "余额已为负,直接终止回测: $currentBalance"
else "余额<=1,停止回测: $currentBalance"
)
terminateBacktest = true
break
}
// 5.3 应用过滤规则
val copyTrading = taskToCopyTrading(task)
val filterResult = copyTradingFilterService.checkFilters(
copyTrading,
tokenId = "",
tradePrice = leaderTrade.price,
copyOrderAmount = null,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
marketEndDate = null,
outcomeIndex = leaderTrade.outcomeIndex
)
if (!filterResult.isPassed) {
logger.debug("交易被过滤: ${leaderTrade.tradeId}")
continue
}
// 5.4 每日订单数检查 - 使用缓存,只统计 BUY 订单
val tradeDate = formatDate(leaderTrade.timestamp)
val dailyOrderCount = dailyOrderCountCache.getOrDefault(tradeDate, 0)
if (dailyOrderCount >= task.maxDailyOrders) {
logger.info("已达到每日最大 BUY 订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
continue
}
// 5.6 计算跟单金额
val followAmount = calculateFollowAmount(task, leaderTrade)
// 5.6.1 检查订单大小限制
val finalFollowAmount = if (followAmount > task.maxOrderSize) {
logger.info("跟单金额超过最大限制: $followAmount > ${task.maxOrderSize},调整为最大值")
task.maxOrderSize
} else if (followAmount < task.minOrderSize) {
logger.info("跟单金额低于最小限制: $followAmount < ${task.minOrderSize},调整为最小值")
task.minOrderSize
} else {
followAmount
}
// 5.6.2 检查每日最大亏损(买入订单)- 使用缓存
val dailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
if (dailyLoss > task.maxDailyLoss) {
logger.info("已达到每日最大亏损限制: $dailyLoss / ${task.maxDailyLoss},跳过买入订单")
continue
}
// 5.7 处理买卖逻辑
if (leaderTrade.side == "BUY") {
// 余额不足时按最大可用余额交易,仍须满足最小订单金额
val actualBuyAmount = if (currentBalance < finalFollowAmount) {
logger.debug("余额不足,按最大余额买入: balance=$currentBalance, 原需=$finalFollowAmount, marketId=${leaderTrade.marketId}")
currentBalance
} else {
finalFollowAmount
}
if (actualBuyAmount < task.minOrderSize) {
logger.debug("可用金额低于最小订单限制跳过: actual=$actualBuyAmount, minOrderSize=${task.minOrderSize}")
continue
}
val quantity = actualBuyAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN)
if (quantity <= BigDecimal.ZERO) {
logger.debug("计算数量为0跳过: actualBuyAmount=$actualBuyAmount, price=${leaderTrade.price}")
continue
}
val totalCost = actualBuyAmount
// 5.6.3 检查最大仓位限制(如果配置了)
if (task.maxPositionValue != null) {
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val currentPosition = positions[positionKey]
val currentPositionValue = if (currentPosition != null) {
currentPosition.quantity.multiply(currentPosition.avgPrice)
} else {
BigDecimal.ZERO
}
val totalValueAfterOrder = currentPositionValue.add(actualBuyAmount)
if (totalValueAfterOrder.gt(task.maxPositionValue)) {
val currentPositionValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
val maxValueStr = task.maxPositionValue.stripTrailingZeros().toPlainString()
logger.info("超过最大仓位金额限制: 市场=${leaderTrade.marketId}, 方向=${leaderTrade.outcomeIndex}, 当前仓位=${currentPositionValueStr} USDC, 买入金额=${actualBuyAmount} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC")
continue
}
}
// 更新余额和持仓(同市场同 outcome 多次买入合并:数量相加、加权均价、leaderBuyQuantity 相加)
currentBalance -= totalCost
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val price = leaderTrade.price.toSafeBigDecimal()
val leaderSize = leaderTrade.size.toSafeBigDecimal()
val existing = positions[positionKey]
positions[positionKey] = if (existing != null) {
val newQuantity = existing.quantity.add(quantity)
val newAvgPrice = if (newQuantity > BigDecimal.ZERO) {
existing.quantity.multiply(existing.avgPrice).add(quantity.multiply(price))
.divide(newQuantity, 8, java.math.RoundingMode.HALF_UP)
} else {
price
}
val newLeaderBuyQuantity = (existing.leaderBuyQuantity ?: BigDecimal.ZERO).add(leaderSize)
Position(
marketId = leaderTrade.marketId,
outcome = leaderTrade.outcome ?: "",
outcomeIndex = leaderTrade.outcomeIndex,
quantity = newQuantity,
avgPrice = newAvgPrice,
leaderBuyQuantity = newLeaderBuyQuantity,
marketEndDate = existing.marketEndDate
)
} else {
val market = marketService.getMarket(leaderTrade.marketId)
Position(
marketId = leaderTrade.marketId,
outcome = leaderTrade.outcome ?: "",
outcomeIndex = leaderTrade.outcomeIndex,
quantity = quantity,
avgPrice = price,
leaderBuyQuantity = leaderSize,
marketEndDate = market?.endDate
)
}
// 记录交易到当前页列表
currentPageTrades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = leaderTrade.timestamp,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
side = "BUY",
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
outcomeIndex = leaderTrade.outcomeIndex,
quantity = quantity,
price = leaderTrade.price.toSafeBigDecimal(),
amount = actualBuyAmount,
fee = BigDecimal.ZERO,
profitLoss = null,
balanceAfter = currentBalance,
leaderTradeId = leaderTrade.tradeId
))
// 更新每日订单数缓存
dailyOrderCountCache[tradeDate] = dailyOrderCount + 1
} else {
// SELL 逻辑
if (!task.supportSell) {
continue
}
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val position = positions[positionKey] ?: continue
// 计算卖出数量
val sellQuantity = if (task.copyMode == "RATIO") {
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
position.quantity.multiply(
leaderTrade.size.divide(position.leaderBuyQuantity, 8, java.math.RoundingMode.DOWN)
)
} else {
position.quantity
}
} else {
position.quantity
}
val actualSellQuantity = if (sellQuantity > position.quantity) {
position.quantity
} else {
sellQuantity
}
// 计算卖出金额
val sellAmount = actualSellQuantity.multiply(leaderTrade.price.toSafeBigDecimal())
// 5.6.2 检查卖出金额限制
val finalSellAmount = if (sellAmount > task.maxOrderSize) {
logger.info("卖出金额超过最大限制: $sellAmount > ${task.maxOrderSize},调整为最大值")
task.maxOrderSize
} else if (sellAmount < task.minOrderSize) {
logger.info("卖出金额低于最小限制: $sellAmount < ${task.minOrderSize},调整为最小值")
task.minOrderSize
} else {
sellAmount
}
val netAmount = finalSellAmount
// 计算盈亏
val cost = actualSellQuantity.multiply(position.avgPrice)
val profitLoss = netAmount.subtract(cost)
// 更新余额和持仓
currentBalance += netAmount
if (position.quantity <= BigDecimal.ZERO) {
positions.remove(positionKey)
}
// 记录交易到当前页列表
currentPageTrades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = leaderTrade.timestamp,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
side = "SELL",
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
outcomeIndex = leaderTrade.outcomeIndex,
quantity = actualSellQuantity,
price = leaderTrade.price.toSafeBigDecimal(),
amount = finalSellAmount,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = currentBalance,
leaderTradeId = leaderTrade.tradeId
))
// SELL 订单不计入每日订单数限制
// 更新每日亏损缓存(只累加亏损,不累加盈利)
if (profitLoss < BigDecimal.ZERO) {
val currentDailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
dailyLossCache[tradeDate] = currentDailyLoss + profitLoss.negate()
}
}
} catch (e: Exception) {
logger.error("处理交易失败: tradeId=${leaderTrade.tradeId}", e)
}
}
// 保存本批交易
if (currentPageTrades.isNotEmpty()) {
logger.info("保存本批交易,共 ${currentPageTrades.size}")
backtestTradeRepository.saveAll(currentPageTrades)
val lastTradeInPage = currentPageTrades.lastOrNull()
if (lastTradeInPage != null && lastProcessedIndexInPage != null) {
task.lastProcessedTradeTime = lastTradeInPage.tradeTime
task.lastProcessedTradeIndex = lastProcessedIndexInPage
task.processedTradeCount = lastProcessedIndexInPage + 1
task.finalBalance = currentBalance
backtestTaskRepository.save(task)
logger.info("本批处理完成,lastProcessedTradeIndex=${task.lastProcessedTradeIndex}, 总处理数=${task.processedTradeCount}")
}
} else {
logger.info("本批没有交易需要保存")
}
trades.addAll(currentPageTrades)
if (batch.nextCursorSeconds == null) {
logger.info("本批不足 $backtestBatchLimit 条,已是最后一页")
break
}
cursorSeconds = batch.nextCursorSeconds!!
} catch (e: Exception) {
logger.error("获取或处理本批数据失败: ${e.message}", e)
// 重试失败,标记任务为 FAILED
throw e
}
}
// 6. 处理回测结束时仍未到期的持仓
val remainingSettlements = mutableListOf<BacktestTrade>()
currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime, remainingSettlements)
if (remainingSettlements.isNotEmpty()) {
backtestTradeRepository.saveAll(remainingSettlements)
logger.info("回测结束结算剩余持仓,持久化 ${remainingSettlements.size} 笔 SETTLEMENT(CLOSED)")
}
// 7. 计算最终统计数据
val statistics = calculateStatistics(trades)
// 8. 更新任务状态
val profitAmount = currentBalance.subtract(task.initialBalance)
val profitRate = if (task.initialBalance > BigDecimal.ZERO) {
profitAmount.divide(task.initialBalance, 4, java.math.RoundingMode.HALF_UP).multiply(BigDecimal("100"))
} else {
BigDecimal.ZERO
}
val finalStatus = if (task.status == "STOPPED") "STOPPED" else "COMPLETED"
task.finalBalance = currentBalance
task.profitAmount = profitAmount
task.profitRate = profitRate
task.endTime = endTime
task.status = finalStatus
task.progress = 100
task.totalTrades = trades.size
task.buyTrades = trades.count { it.side == "BUY" }
task.sellTrades = trades.count { it.side == "SELL" }
task.winTrades = statistics.winTrades
task.lossTrades = statistics.lossTrades
task.winRate = statistics.winRate.toSafeBigDecimal()
task.maxProfit = statistics.maxProfit.toSafeBigDecimal()
task.maxLoss = statistics.maxLoss.toSafeBigDecimal()
task.maxDrawdown = statistics.maxDrawdown.toSafeBigDecimal()
task.avgHoldingTime = statistics.avgHoldingTime
task.executionFinishedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
logger.info("回测任务执行完成: taskId=${task.id}, " +
"最终余额=${currentBalance.toPlainString()}, " +
"收益额=${task.profitAmount?.toPlainString()}, " +
"收益率=${task.profitRate?.toPlainString()}%, " +
"总交易数=${trades.size}, " +
"盈利率=${task.winRate?.toPlainString()}%")
} catch (e: Exception) {
logger.error("回测任务执行失败: taskId=${task.id}", e)
task.status = "FAILED"
task.errorMessage = e.message
task.executionFinishedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
throw e
}
}
/**
* 结算已到期的市场
* @param batchTradesToSave 本批要持久化的交易列表到期结算赎回/会追加到此列表并随本批一起落库
*/
private suspend fun settleExpiredPositions(
task: BacktestTask,
positions: MutableMap<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long,
batchTradesToSave: MutableList<BacktestTrade>
): BigDecimal {
var balance = currentBalance
for ((positionKey, position) in positions.toList()) {
try {
// 仅当市场已到期(结束时间 <= 当前回测时间)时才结算,避免未到期持仓被误结算
if (position.marketEndDate == null || position.marketEndDate!! > currentTime) {
logger.debug("持仓未到期跳过结算: marketId=${position.marketId}, endDate=${position.marketEndDate}, currentTime=$currentTime")
continue
}
// 获取市场当前价格
val marketPrice = marketPriceService.getCurrentMarketPrice(
position.marketId,
position.outcomeIndex ?: 0
)
val price = marketPrice.toSafeBigDecimal()
// 通过市场价格判断结算价格
val settlementPrice = when {
price >= BigDecimal("0.95") -> BigDecimal.ONE
price <= BigDecimal("0.05") -> BigDecimal.ZERO
else -> position.avgPrice
}
val settlementValue = position.quantity.multiply(settlementPrice)
val profitLoss = settlementValue.subtract(position.quantity.multiply(position.avgPrice))
balance += settlementValue
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
val settlementTrade = BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = currentTime,
marketId = position.marketId,
marketTitle = marketTitle,
side = "SETTLEMENT",
outcome = when {
settlementPrice == BigDecimal.ONE -> "WIN"
settlementPrice == BigDecimal.ZERO -> "LOSE"
else -> "UNKNOWN"
},
outcomeIndex = position.outcomeIndex,
quantity = position.quantity,
price = settlementPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = balance,
leaderTradeId = null
)
trades.add(settlementTrade)
batchTradesToSave.add(settlementTrade)
// 移除已结算的持仓
positions.remove(positionKey)
} catch (e: Exception) {
logger.error("结算市场失败: marketId=${position.marketId}, outcomeIndex=${position.outcomeIndex}", e)
}
}
return balance
}
/**
* 结算未到期持仓回测结束时剩余持仓按均价平仓
* @param settlementsToSave 本批结算记录会追加到此列表调用方需落库
*/
private suspend fun settleRemainingPositions(
task: BacktestTask,
positions: MutableMap<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long,
settlementsToSave: MutableList<BacktestTrade>
): BigDecimal {
var balance = currentBalance
for ((positionKey, position) in positions.toList()) {
val quantity = position.quantity
val avgPrice = position.avgPrice
val settlementPrice = avgPrice
val settlementValue = quantity.multiply(settlementPrice)
val profitLoss = settlementValue.negate()
balance += settlementValue
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
val closedTrade = BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = currentTime,
marketId = position.marketId,
marketTitle = marketTitle,
side = "SETTLEMENT",
outcome = "CLOSED",
outcomeIndex = position.outcomeIndex,
quantity = quantity,
price = avgPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = balance,
leaderTradeId = null
)
trades.add(closedTrade)
settlementsToSave.add(closedTrade)
}
positions.clear()
return balance
}
/**
* 计算统计数据
*/
private fun calculateStatistics(trades: List<BacktestTrade>): BacktestStatisticsDto {
val buyTrades = trades.count { it.side == "BUY" }
val sellTrades = trades.count { it.side == "SELL" }
val winTrades = trades.count { it.profitLoss != null && it.profitLoss > BigDecimal.ZERO }
val lossTrades = trades.count { it.profitLoss != null && it.profitLoss < BigDecimal.ZERO }
var totalProfit = BigDecimal.ZERO
var totalLoss = BigDecimal.ZERO
var maxProfit = BigDecimal.ZERO
var maxLoss = BigDecimal.ZERO
// 计算最大回撤
var runningBalance = if (trades.isNotEmpty()) {
trades[0].balanceAfter?.toSafeBigDecimal() ?: BigDecimal.ZERO
} else {
BigDecimal.ZERO
}
var peakBalance = runningBalance
var maxDrawdown = BigDecimal.ZERO
for (i in trades.indices) {
val trade = trades[i]
val balance = trade.balanceAfter?.toSafeBigDecimal() ?: continue
if (trade.profitLoss != null) {
val pnl = trade.profitLoss.toSafeBigDecimal()
if (pnl > BigDecimal.ZERO) {
totalProfit += pnl
if (pnl > maxProfit) maxProfit = pnl
} else {
totalLoss += pnl
if (pnl < maxLoss) maxLoss = pnl
}
}
if (balance > peakBalance) {
peakBalance = balance
}
val drawdown = peakBalance - runningBalance
if (drawdown > maxDrawdown) {
maxDrawdown = drawdown
}
runningBalance = balance
}
// 计算平均持仓时间
var avgHoldingTime: Long? = null
if (trades.size > 1) {
var totalHoldingTime = 0L
var count = 0
for (i in 0 until trades.size - 1) {
val currentTrade = trades[i]
val nextTrade = trades[i + 1]
if (currentTrade.side == "BUY" && nextTrade.side == "SELL") {
val holdingTime = nextTrade.tradeTime - currentTrade.tradeTime
totalHoldingTime += holdingTime
count++
}
}
if (count > 0) {
avgHoldingTime = totalHoldingTime / count
}
}
return BacktestStatisticsDto(
totalTrades = trades.size,
buyTrades = buyTrades,
sellTrades = sellTrades,
winTrades = winTrades,
lossTrades = lossTrades,
winRate = if (buyTrades + sellTrades > 0) {
(winTrades.toBigDecimal().divide((buyTrades + sellTrades).toBigDecimal(), 4, java.math.RoundingMode.HALF_UP))
.multiply(BigDecimal("100"))
.toPlainString()
} else {
BigDecimal.ZERO.toPlainString()
},
maxProfit = maxProfit.toPlainString(),
maxLoss = maxLoss.toPlainString(),
maxDrawdown = maxDrawdown.toPlainString(),
avgHoldingTime = avgHoldingTime
)
}
/**
* 计算跟单金额
*/
private fun calculateFollowAmount(task: BacktestTask, leaderTrade: TradeData): BigDecimal {
return if (task.copyMode == "RATIO") {
// 比例模式:Leader 成交金额 × 跟单比例
leaderTrade.amount.toSafeBigDecimal().multiply(task.copyRatio)
} else {
// 固定金额模式:使用配置的固定金额
task.fixedAmount ?: leaderTrade.amount.toSafeBigDecimal()
}
}
/**
* 判断是否同一天
*/
private fun isSameDay(timestamp1: Long, timestamp2: Long): Boolean {
val cal1 = Calendar.getInstance().apply { timeInMillis = timestamp1 }
val cal2 = Calendar.getInstance().apply { timeInMillis = timestamp2 }
return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR)
}
/**
* 格式化时间戳
*/
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return sdf.format(Date(timestamp))
}
/**
* 格式化日期用于缓存key
*/
private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd")
return sdf.format(Date(timestamp))
}
}
@@ -1,113 +0,0 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.ThreadPoolExecutor
import kotlinx.coroutines.runBlocking
/**
* 回测轮询服务
* 定时获取待执行的回测任务并执行
*/
@Service
class BacktestPollingService(
private val backtestTaskRepository: BacktestTaskRepository,
private val executionService: BacktestExecutionService
) {
private val logger = LoggerFactory.getLogger(BacktestPollingService::class.java)
// 线程池:同一时刻只执行一个任务
private val executor: ExecutorService = Executors.newFixedThreadPool(1) as ThreadPoolExecutor
/**
* 轮询待执行的回测任务
* 10 秒执行一次
* 规则同一时刻只执行一个任务如果有多个待执行任务按创建时间先后执行最早创建的
*/
@Scheduled(fixedDelay = 10000) // 10 秒
fun pollPendingTasks() {
try {
// 1. 检查是否有长时间处于 RUNNING 状态的任务(可能是应用重启导致的)
val runningTasks = backtestTaskRepository.findByStatus("RUNNING")
if (runningTasks.isNotEmpty()) {
val activeQueueSize = (executor as ThreadPoolExecutor).queue.size
val activeCount = (executor as ThreadPoolExecutor).activeCount
// 如果有线程池中没有活跃任务但有 RUNNING 状态的任务,说明是应用重启导致的
// 重置这些任务的状态为 PENDING,以便恢复执行
if (activeCount == 0 && runningTasks.isNotEmpty()) {
logger.info("检测到应用重启导致的异常 RUNNING 任务,重置为 PENDING 以便恢复")
runningTasks.forEach { task ->
val now = System.currentTimeMillis()
val executionStartedAt = task.executionStartedAt
val executionDuration = if (executionStartedAt != null) {
now - executionStartedAt
} else {
0L
}
// 如果任务执行时间超过 1 分钟,认为是异常状态
if (executionDuration > 60000) {
logger.info("重置异常 RUNNING 任务: taskId=${task.id}, executionStartedAt=$executionStartedAt, duration=${executionDuration}ms")
task.status = "PENDING"
task.updatedAt = now
backtestTaskRepository.save(task)
}
}
} else {
logger.debug("${runningTasks.size} 个任务正在执行,跳过本次轮询")
return
}
}
// 2. 查询所有 PENDING 状态的任务,按创建时间升序排序
val pendingTasks = backtestTaskRepository.findByStatus("PENDING")
.sortedBy { it.createdAt }
if (pendingTasks.isEmpty()) {
return
}
// 3. 只执行最早创建的任务
val taskToExecute = pendingTasks.first()
logger.info("找到 ${pendingTasks.size} 个待执行的回测任务,执行最早创建的任务: taskId=${taskToExecute.id}, createdAt=${taskToExecute.createdAt}")
// 4. 提交任务到线程池执行
executor.submit {
try {
// 执行前再次检查任务状态(防止并发执行)
val currentTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
if (currentTask == null || currentTask.status != "PENDING") {
logger.debug("任务状态已变更,跳过执行: taskId=${taskToExecute.id}, currentStatus=${currentTask?.status}")
return@submit
}
runBlocking {
// 使用 start 游标分页,恢复时由 lastProcessedTradeTime 决定从何时开始拉取
logger.info("执行回测任务: taskId=${currentTask.id}(游标分页,limit=500")
executionService.executeBacktest(currentTask, page = 0, size = 500)
}
} catch (e: Exception) {
logger.error("回测任务执行失败: taskId=${taskToExecute.id}", e)
// 更新任务状态为 FAILED
val failedTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
if (failedTask != null) {
failedTask.status = "FAILED"
failedTask.errorMessage = e.message
failedTask.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(failedTask)
}
}
}
} catch (e: Exception) {
logger.error("轮询回测任务失败", e)
}
}
}
@@ -1,426 +0,0 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.entity.BacktestTrade
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.toJson
import com.wrbug.polymarketbot.util.fromJson
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
/**
* 回测任务服务
*/
@Service
class BacktestService(
private val backtestTaskRepository: BacktestTaskRepository,
private val backtestTradeRepository: BacktestTradeRepository,
private val leaderRepository: LeaderRepository,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(BacktestService::class.java)
/**
* 创建回测任务
*/
@Transactional
fun createBacktestTask(request: BacktestCreateRequest): Result<BacktestTaskDto> {
return try {
// 1. 验证 Leader 是否存在
val leader = leaderRepository.findById(request.leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// 2. 验证回测天数
if (request.backtestDays < 1 || request.backtestDays > 15) {
return Result.failure(IllegalArgumentException("回测天数必须在 1-15 之间"))
}
// 3. 验证恢复页码(如果提供)
if (request.pageForResume != null && request.pageForResume < 1) {
return Result.failure(IllegalArgumentException("恢复页码必须大于 0"))
}
// 4. 验证初始金额
val initialBalance = request.initialBalance.toSafeBigDecimal()
if (initialBalance <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException("初始金额必须大于 0"))
}
// 4. 创建回测任务
val task = BacktestTask(
taskName = request.taskName.trim(),
leaderId = request.leaderId,
initialBalance = initialBalance,
backtestDays = request.backtestDays,
startTime = System.currentTimeMillis() - (request.backtestDays * 24 * 3600 * 1000),
status = "PENDING",
// 跟单配置(不包含 max_position_count
copyMode = request.copyMode ?: "RATIO",
copyRatio = request.copyRatio?.toSafeBigDecimal() ?: BigDecimal.ONE,
fixedAmount = request.fixedAmount?.toSafeBigDecimal(),
maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: "1000".toSafeBigDecimal(),
minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: "1".toSafeBigDecimal(),
maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: "10000".toSafeBigDecimal(),
maxDailyOrders = request.maxDailyOrders ?: 100,
supportSell = request.supportSell ?: true,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = if (request.keywords != null && request.keywords.isNotEmpty()) {
request.keywords.toJson()
} else {
null
},
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
)
backtestTaskRepository.save(task)
// 5. 转换为 DTO 返回
Result.success(task.toDto(leader))
} catch (e: Exception) {
logger.error("创建回测任务失败", e)
Result.failure(e)
}
}
/**
* 查询回测任务列表
*/
fun getBacktestTaskList(request: BacktestListRequest): Result<BacktestListResponse> {
return try {
// 获取所有符合条件的任务
val allTasks = when {
request.leaderId != null && request.status != null -> {
backtestTaskRepository.findByLeaderIdAndStatus(request.leaderId, request.status)
}
request.leaderId != null -> {
backtestTaskRepository.findByLeaderId(request.leaderId)
.filter { request.status == null || it.status == request.status }
}
request.status != null -> {
backtestTaskRepository.findByStatus(request.status)
}
else -> {
backtestTaskRepository.findAll()
}
}
// 排序
val sortedTasks = when (request.sortBy) {
"profitAmount" -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.profitAmount }
} else {
allTasks.sortedByDescending { it.profitAmount }
}
}
"profitRate" -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.profitRate }
} else {
allTasks.sortedByDescending { it.profitRate }
}
}
else -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.createdAt }
} else {
allTasks.sortedByDescending { it.createdAt }
}
}
}
// 分页
val total = sortedTasks.size
val pagedTasks = sortedTasks
.drop((request.page - 1) * request.size)
.take(request.size)
val list = pagedTasks.map { task ->
val leader = leaderRepository.findById(task.leaderId).orElse(null)
task.toDto(leader)
}
Result.success(
BacktestListResponse(
list = list,
total = total.toLong(),
page = request.page,
size = request.size
)
)
} catch (e: Exception) {
logger.error("查询回测任务列表失败", e)
Result.failure(e)
}
}
/**
* 查询回测任务详情
*/
fun getBacktestTaskDetail(request: BacktestDetailRequest): Result<BacktestDetailResponse> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
val leader = leaderRepository.findById(task.leaderId).orElse(null)
val config = BacktestConfigDto(
copyMode = task.copyMode,
copyRatio = task.copyRatio.toPlainString(),
fixedAmount = task.fixedAmount?.toPlainString(),
maxOrderSize = task.maxOrderSize.toPlainString(),
minOrderSize = task.minOrderSize.toPlainString(),
maxDailyLoss = task.maxDailyLoss.toPlainString(),
maxDailyOrders = task.maxDailyOrders,
supportSell = task.supportSell,
keywordFilterMode = task.keywordFilterMode,
keywords = if (task.keywords != null) {
task.keywords.fromJson<List<String>>()
} else {
emptyList()
},
maxPositionValue = task.maxPositionValue?.toPlainString(),
minPrice = task.minPrice?.toPlainString(),
maxPrice = task.maxPrice?.toPlainString()
)
val statistics = BacktestStatisticsDto(
totalTrades = task.totalTrades,
buyTrades = task.buyTrades,
sellTrades = task.sellTrades,
winTrades = task.winTrades,
lossTrades = task.lossTrades,
winRate = task.winRate?.toPlainString() ?: "0.00",
maxProfit = task.maxProfit?.toPlainString() ?: "0.00",
maxLoss = task.maxLoss?.toPlainString() ?: "0.00",
maxDrawdown = task.maxDrawdown?.toPlainString() ?: "0.00",
avgHoldingTime = task.avgHoldingTime
)
val taskDto = task.toDto(leader)
Result.success(
BacktestDetailResponse(
task = taskDto,
config = config,
statistics = statistics
)
)
} catch (e: Exception) {
logger.error("查询回测任务详情失败", e)
Result.failure(e)
}
}
/**
* 查询回测交易记录
*/
fun getBacktestTrades(request: BacktestTradeListRequest): Result<BacktestTradeListResponse> {
return try {
val pageRequest = PageRequest.of(
request.page - 1,
request.size,
Sort.by(Sort.Order.asc("tradeTime"))
)
val tradesPage = backtestTradeRepository.findByBacktestTaskId(
request.taskId,
pageRequest
)
val list = tradesPage.content.map { trade ->
BacktestTradeDto(
id = trade.id!!,
tradeTime = trade.tradeTime,
marketId = trade.marketId,
marketTitle = trade.marketTitle,
side = trade.side,
outcome = trade.outcome,
outcomeIndex = trade.outcomeIndex,
quantity = trade.quantity.toPlainString(),
price = trade.price.toPlainString(),
amount = trade.amount.toPlainString(),
fee = trade.fee.toPlainString(),
profitLoss = trade.profitLoss?.toPlainString(),
balanceAfter = trade.balanceAfter.toPlainString(),
leaderTradeId = trade.leaderTradeId
)
}
Result.success(
BacktestTradeListResponse(
list = list,
total = tradesPage.totalElements,
page = request.page,
size = request.size
)
)
} catch (e: Exception) {
logger.error("查询回测交易记录失败", e)
Result.failure(e)
}
}
/**
* 删除回测任务
*/
@Transactional
fun deleteBacktestTask(request: BacktestDeleteRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status == "RUNNING") {
return Result.failure(IllegalStateException("回测任务正在运行,无法删除"))
}
backtestTaskRepository.deleteById(request.id)
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除回测任务失败", e)
Result.failure(e)
}
}
/**
* 停止回测任务
*/
@Transactional
fun stopBacktestTask(request: BacktestStopRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status != "RUNNING") {
return Result.failure(IllegalArgumentException("回测任务未在运行中"))
}
task.status = "STOPPED"
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
Result.success(Unit)
} catch (e: Exception) {
logger.error("停止回测任务失败", e)
Result.failure(e)
}
}
/**
* 重试回测任务
* 从断点继续执行保留已处理的交易记录
*/
@Transactional
fun retryBacktestTask(request: BacktestRetryRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status == "RUNNING") {
return Result.failure(IllegalArgumentException("回测任务正在运行中,无需重试"))
}
// 重置任务状态为 PENDING,进度保持不变
task.status = "PENDING"
task.errorMessage = null
task.updatedAt = System.currentTimeMillis()
// 不清理已处理的交易记录,保留恢复点
backtestTaskRepository.save(task)
Result.success(Unit)
} catch (e: Exception) {
logger.error("重试回测任务失败", e)
Result.failure(e)
}
}
/**
* 按当前配置重新测试基于已完成的回测任务创建一份相同配置的新任务名称可修改
*/
@Transactional
fun rerunBacktestTask(request: BacktestRerunRequest): Result<BacktestTaskDto> {
return try {
val source = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (source.status != "COMPLETED") {
return Result.failure(IllegalStateException("仅支持对已完成的回测任务重新测试"))
}
val newTaskName = request.taskName?.trim()?.takeIf { it.isNotEmpty() }
?: "${source.taskName} (副本)"
val newTask = BacktestTask(
taskName = newTaskName,
leaderId = source.leaderId,
initialBalance = source.initialBalance,
backtestDays = source.backtestDays,
startTime = source.startTime,
status = "PENDING",
copyMode = source.copyMode,
copyRatio = source.copyRatio,
fixedAmount = source.fixedAmount,
maxOrderSize = source.maxOrderSize,
minOrderSize = source.minOrderSize,
maxDailyLoss = source.maxDailyLoss,
maxDailyOrders = source.maxDailyOrders,
supportSell = source.supportSell,
keywordFilterMode = source.keywordFilterMode,
keywords = source.keywords,
maxPositionValue = source.maxPositionValue,
minPrice = source.minPrice,
maxPrice = source.maxPrice
)
backtestTaskRepository.save(newTask)
val leader = leaderRepository.findById(newTask.leaderId).orElse(null)
Result.success(newTask.toDto(leader))
} catch (e: Exception) {
logger.error("按配置重新测试失败", e)
Result.failure(e)
}
}
}
/**
* 扩展函数BacktestTask DTO
*/
private fun BacktestTask.toDto(leader: Leader?): BacktestTaskDto {
return BacktestTaskDto(
id = this.id!!,
taskName = this.taskName,
leaderId = this.leaderId,
leaderName = leader?.leaderName,
leaderAddress = leader?.leaderAddress,
initialBalance = this.initialBalance.toPlainString(),
finalBalance = this.finalBalance?.toPlainString(),
profitAmount = this.profitAmount?.toPlainString(),
profitRate = this.profitRate?.toPlainString(),
backtestDays = this.backtestDays,
startTime = this.startTime,
endTime = this.endTime,
status = this.status,
progress = this.progress,
totalTrades = this.totalTrades,
createdAt = this.createdAt,
executionStartedAt = this.executionStartedAt,
executionFinishedAt = this.executionFinishedAt
)
}
@@ -7,15 +7,11 @@ import com.wrbug.polymarketbot.api.JsonRpcResponse
import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.PositionResponse
import com.wrbug.polymarketbot.api.ValueResponse
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.dto.PositionDto
import com.wrbug.polymarketbot.dto.WalletBalanceResponse
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.service.system.RpcNodeService
import org.springframework.stereotype.Service
@@ -30,6 +26,8 @@ import java.math.BigInteger
*/
@Service
class BlockchainService(
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
private val dataApiBaseUrl: String,
private val retrofitFactory: RetrofitFactory,
private val relayClientService: RelayClientService,
private val rpcNodeService: RpcNodeService,
@@ -63,10 +61,10 @@ class BlockchainService(
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
private val dataApi: PolymarketDataApi by lazy {
val baseUrl = if (PolymarketConstants.DATA_API_BASE_URL.endsWith("/")) {
PolymarketConstants.DATA_API_BASE_URL.dropLast(1)
val baseUrl = if (dataApiBaseUrl.endsWith("/")) {
dataApiBaseUrl.dropLast(1)
} else {
PolymarketConstants.DATA_API_BASE_URL
dataApiBaseUrl
}
val okHttpClient = createClient()
.followRedirects(true)
@@ -94,13 +92,13 @@ class BlockchainService(
* 2. Safe ProxyMetaMask 钱包用户- 通过合约调用获取地址
*
* @param walletAddress 用户的钱包地址EOA
* @param walletType 钱包类型MAGIC默认 SAFE
* @param walletType 钱包类型"magic"默认 "safe"
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String, walletType: WalletType = WalletType.MAGIC): Result<String> {
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
return try {
when (walletType) {
WalletType.SAFE -> {
when (walletType.lowercase()) {
"safe" -> {
// Safe ProxyMetaMask 用户)
val safeProxyResult = getSafeProxyAddress(walletAddress)
if (safeProxyResult.isSuccess) {
@@ -111,7 +109,7 @@ class BlockchainService(
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
}
}
WalletType.MAGIC -> {
else -> {
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
@@ -259,6 +257,7 @@ class BlockchainService(
return Result.failure(IllegalArgumentException("代理地址不能为空"))
}
// 使用 RPC 查询 USDC 余额(使用代理地址)
val balance = queryUsdcBalanceViaRpc(proxyAddress)
Result.success(balance)
@@ -267,82 +266,6 @@ class BlockchainService(
Result.failure(e)
}
}
/**
* 查询钱包余额通用方法
* 用于 Account Leader 的余额查询
* @param walletAddress 钱包地址代理地址Polymarket 使用代理地址存储资产
* @return WalletBalanceResponse 包含可用余额仓位余额总余额和持仓列表
*/
suspend fun getWalletBalance(walletAddress: String): Result<WalletBalanceResponse> {
return try {
if (walletAddress.isBlank()) {
logger.error("钱包地址为空,无法查询余额")
return Result.failure(IllegalArgumentException("钱包地址不能为空"))
}
// 1. 查询持仓信息(用于返回持仓列表)
val positionsResult = getPositions(walletAddress)
val positions = if (positionsResult.isSuccess) {
// 过滤掉价值为0的仓位
positionsResult.getOrNull()?.filter { pos ->
val currentValue = pos.currentValue ?: 0.0
currentValue > 0
}?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
title = pos.title,
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 2. 使用 /value 接口获取仓位总价值
val positionBalanceResult = getTotalValue(walletAddress)
val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
}
// 3. 查询可用余额(通过 RPC 查询 USDC 余额)
val availableBalanceResult = getUsdcBalance(
walletAddress = walletAddress,
proxyAddress = walletAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 4. 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
Result.success(
WalletBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(),
positions = positions
)
)
} catch (e: Exception) {
logger.error("查询钱包余额失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 RPC 查询 USDC 余额
@@ -581,35 +504,39 @@ class BlockchainService(
/**
* 赎回仓位
* Safe 账户通过代理 execTransaction 调用Magic 账户通过 Builder Relayer PROXYGasless执行
*
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
*
* 使用 RelayClientService 实现完全参考 TypeScript 项目的实现方式
*
* @param privateKey 私钥原始钱包的私钥用于签名交易
* @param proxyAddress 代理地址Safe Magic 代理钱包地址
* @param proxyAddress 代理地址Gnosis Safe 代理钱包地址
* @param conditionId 市场条件IDbytes32必须是 0x 开头的 66 位十六进制字符串
* @param indexSets 要赎回的索引集合列表每个元素是 2^outcomeIndex
* @param walletType 钱包类型MAGIC SAFE用于选择执行路径
* @param indexSets 要赎回的索引集合列表每个元素是 2^outcomeIndex例如 [1] 表示 outcome 0[2] 表示 outcome 1
* @return 交易哈希
*/
suspend fun redeemPositions(
privateKey: String,
proxyAddress: String,
conditionId: String,
indexSets: List<BigInteger>,
walletType: WalletType = WalletType.SAFE
indexSets: List<BigInteger>
): Result<String> {
return try {
// 验证参数
if (indexSets.isEmpty()) {
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
}
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
}
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 使用 RelayClientService 创建赎回交易并执行
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
relayClientService.execute(privateKey, proxyAddress, redeemTx)
} catch (e: Exception) {
logger.error("赎回仓位失败: ${e.message}", e)
Result.failure(e)
@@ -788,7 +715,7 @@ class BlockchainService(
/**
* 从链上查询市场条件Condition的结算结果
* 通过调用 ConditionalTokens 合约的 conditions mapping payoutNumerators mapping
* 通过调用 ConditionalTokens 合约的 getCondition 函数获取 payouts
*
* @param conditionId 市场条件IDbytes32必须是 0x 开头的 66 位十六进制字符串
* @return Result<Pair<payoutDenominator, payouts>>
@@ -807,131 +734,44 @@ class BlockchainService(
val rpcApi = polygonRpcApi
// 1. 调用 getOutcomeSlotCount(bytes32) 获取结果槽位数量
// 函数签名: getOutcomeSlotCount(bytes32) returns (uint)
val getOutcomeSlotCountSelector = EthereumUtils.getFunctionSelector("getOutcomeSlotCount(bytes32)")
// 构建 getCondition(bytes32) 函数调用
// 函数签名: getCondition(bytes32)
val functionSelector = EthereumUtils.getFunctionSelector("getCondition(bytes32)")
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
val outcomeSlotCountData = getOutcomeSlotCountSelector + encodedConditionId
val data = functionSelector + encodedConditionId
val outcomeSlotCountRequest = JsonRpcRequest(
// 构建 JSON-RPC 请求
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to conditionalTokensAddress,
"data" to outcomeSlotCountData
"data" to data
),
"latest"
)
)
val outcomeSlotCountResponse = rpcApi.call(outcomeSlotCountRequest)
// 发送 RPC 请求
val response = rpcApi.call(rpcRequest)
if (!outcomeSlotCountResponse.isSuccessful || outcomeSlotCountResponse.body() == null) {
return Result.failure(Exception("RPC 请求失败 (getOutcomeSlotCount): ${outcomeSlotCountResponse.code()} ${outcomeSlotCountResponse.message()}"))
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
}
val outcomeSlotCountRpcResponse = outcomeSlotCountResponse.body()!!
val rpcResponse = response.body()!!
if (outcomeSlotCountRpcResponse.error != null) {
val errorMsg = "RPC 错误 (code=${outcomeSlotCountRpcResponse.error.code}): ${outcomeSlotCountRpcResponse.error.message}, data=${outcomeSlotCountRpcResponse.error.data}"
logger.warn("查询市场条件(getOutcomeSlotCount)出现RPC错误: conditionId=$conditionId, $errorMsg")
logger.debug("RPC 请求详情: to=$conditionalTokensAddress, data=$outcomeSlotCountData")
return Result.failure(Exception(errorMsg))
// 检查错误
if (rpcResponse.error != null) {
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
}
val outcomeSlotCountHex = outcomeSlotCountRpcResponse.result?.asString
// 使用 Gson 解析 resultJsonElement
val hexResult = rpcResponse.result?.asString
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
val outcomeSlotCount = EthereumUtils.decodeUint256(outcomeSlotCountHex).toInt()
// 如果 outcomeSlotCount 为 0,说明市场尚未创建或不存在
if (outcomeSlotCount <= 0) {
logger.debug("市场尚未创建或不存在: conditionId=$conditionId, outcomeSlotCount=$outcomeSlotCount")
return Result.success(Pair(BigInteger.ZERO, emptyList()))
}
// 2. 调用 payoutDenominator(bytes32) 获取分母
// 函数签名: payoutDenominator(bytes32) returns (uint)
val payoutDenominatorSelector = EthereumUtils.getFunctionSelector("payoutDenominator(bytes32)")
val payoutDenominatorData = payoutDenominatorSelector + encodedConditionId
val payoutDenominatorRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to conditionalTokensAddress,
"data" to payoutDenominatorData
),
"latest"
)
)
val payoutDenominatorResponse = rpcApi.call(payoutDenominatorRequest)
if (!payoutDenominatorResponse.isSuccessful || payoutDenominatorResponse.body() == null) {
return Result.failure(Exception("RPC 请求失败 (payoutDenominator): ${payoutDenominatorResponse.code()} ${payoutDenominatorResponse.message()}"))
}
val payoutDenominatorRpcResponse = payoutDenominatorResponse.body()!!
if (payoutDenominatorRpcResponse.error != null) {
val errorMsg = "RPC 错误 (code=${payoutDenominatorRpcResponse.error.code}): ${payoutDenominatorRpcResponse.error.message}, data=${payoutDenominatorRpcResponse.error.data}"
logger.warn("查询市场条件(payoutDenominator)出现RPC错误: conditionId=$conditionId, $errorMsg")
logger.debug("RPC 请求详情: to=$conditionalTokensAddress, data=$payoutDenominatorData")
return Result.failure(Exception(errorMsg))
}
val payoutDenominatorHex = payoutDenominatorRpcResponse.result?.asString
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
val payoutDenominator = EthereumUtils.decodeUint256(payoutDenominatorHex)
// 如果 outcomeSlotCount 为 0,说明市场尚未创建或不存在
if (outcomeSlotCount <= 0) {
logger.debug("市场尚未创建或不存在: conditionId=$conditionId, outcomeSlotCount=$outcomeSlotCount")
return Result.success(Pair(BigInteger.ZERO, emptyList()))
}
// 如果 payoutDenominator 为 0,说明市场尚未结算
if (payoutDenominator == BigInteger.ZERO) {
logger.debug("市场尚未结算: conditionId=$conditionId, payoutDenominator=$payoutDenominator")
return Result.success(Pair(BigInteger.ZERO, emptyList()))
}
// 2. 查询每个 outcome 的 payoutNumerators
val payouts = mutableListOf<BigInteger>()
for (i in 0 until outcomeSlotCount) {
val payoutNumeratorsFunctionSelector = EthereumUtils.getFunctionSelector("payoutNumerators(bytes32,uint256)")
val encodedIndex = EthereumUtils.encodeUint256(BigInteger.valueOf(i.toLong()))
val payoutNumeratorsData = payoutNumeratorsFunctionSelector + encodedConditionId + encodedIndex
val payoutRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to conditionalTokensAddress,
"data" to payoutNumeratorsData
),
"latest"
)
)
val payoutResponse = rpcApi.call(payoutRequest)
if (!payoutResponse.isSuccessful || payoutResponse.body() == null) {
logger.warn("查询 payoutNumerators 失败: index=$i")
continue
}
val payoutRpcResponse = payoutResponse.body()!!
if (payoutRpcResponse.error != null) {
logger.warn("查询 payoutNumerators 错误: index=$i, error=${payoutRpcResponse.error.message}")
continue
}
val payoutHex = payoutRpcResponse.result?.asString ?: "0x0"
val payout = EthereumUtils.decodeUint256(payoutHex)
payouts.add(payout)
}
// 解析 ABI 编码的返回结果
val (payoutDenominator, payouts) = EthereumUtils.decodeConditionResult(hexResult)
Result.success(Pair(payoutDenominator, payouts))
} catch (e: Exception) {
@@ -1,167 +0,0 @@
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 {
// 1. 获取所有买入订单的市场ID(去重)
val allOrders = copyOrderTrackingRepository.findAll()
val marketIds = allOrders.map { it.marketId }.distinct()
if (marketIds.isEmpty()) {
logger.debug("没有找到任何订单,跳过市场信息检查")
return
}
// 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()) {
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)
}
}
}
}
@@ -9,8 +9,6 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.BigInteger
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
/**
* 市场价格服务
@@ -29,27 +27,11 @@ class MarketPriceService(
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
/**
* 已结算市场的价格缓存
* Key: "marketId:outcomeIndex"
* Value: BigDecimal (1.0 0.0)
*
* 缓存策略
* - 最大缓存 10,000 个已结算市场
* - 永不过期已结算的市场状态永不改变
* - 内存占用约: 10,000 * ~100 bytes = ~1MB
*/
private val settledMarketCache: Cache<String, BigDecimal> = Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats() // 启用统计信息
.build()
/**
* 获取当前市场最新价
* 优先级
* 1. 链上查询市场结算结果如果已结算返回 1.0 0.0
* 2. CLOB API 查询订单簿价格最准确优先使用使用 bestBid
* 3. Gamma Market API 查询市场价格快速作为备选
* 2. CLOB API 查询订单簿价格最准确使用 bestBid
*
* 价格会被截位到 4 位小数向下截断不四舍五入用于显示和后续计算
*
@@ -60,35 +42,21 @@ class MarketPriceService(
*/
suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
// 1. 优先从链上查询市场结算结果
val (chainPrice, hasRpcError) = getPriceFromChainCondition(marketId, outcomeIndex)
val chainPrice = getPriceFromChainCondition(marketId, outcomeIndex)
if (chainPrice != null) {
// 截位到 4 位小数(向下截断,不四舍五入)
return chainPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 如果链上查询出现 RPC 错误(execution reverted),说明市场可能不存在或尚未创建
// 在这种情况下,降级到其他数据源(CLOB API 或 Gamma API),而不是直接抛出异常
// 因为 marketId 可能在 API 中存在,但在链上尚未创建
if (hasRpcError) {
logger.debug("链上查询市场条件出现 RPC 错误(execution reverted),降级到 API 查询: marketId=$marketId, outcomeIndex=$outcomeIndex")
}
// 2. 从 CLOB API 查询订单簿价格(最准确,优先使用)
// 2. 从 CLOB API 查询订单簿价格(最准确)
val orderbookPrice = getPriceFromClobOrderbook(marketId, outcomeIndex)
if (orderbookPrice != null) {
// 截位到 4 位小数(向下截断,不四舍五入)
return orderbookPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 3. 从 Gamma Market API 查询市场价格(作为备选)
val marketPrice = getPriceFromGammaMarket(marketId, outcomeIndex)
if (marketPrice != null) {
// 截位到 4 位小数(向下截断,不四舍五入)
return marketPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 如果所有数据源都失败,抛出异常
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询订单簿查询和 Market API 均失败)"
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询订单簿查询均失败)"
logger.error(errorMsg)
throw IllegalStateException(errorMsg)
}
@@ -99,21 +67,8 @@ class MarketPriceService(
* - payout > 0赢了 返回 1.0
* - payout == 0输了 返回 0.0
* 如果市场未结算或查询失败返回 null
*
* 使用缓存优化已结算的市场结果会被缓存避免重复 RPC 调用
*
* @return Pair<BigDecimal?, Boolean> 第一个值是价格如果已结算第二个值表示是否发生了 RPC 错误execution reverted
*/
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
// 1. 先检查缓存
val cacheKey = "$marketId:$outcomeIndex"
val cachedPrice = settledMarketCache.getIfPresent(cacheKey)
if (cachedPrice != null) {
logger.debug("从缓存获取已结算市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$cachedPrice")
return Pair(cachedPrice, false)
}
// 2. 缓存未命中,发起 RPC 查询
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): BigDecimal? {
return try {
val chainResult = blockchainService.getCondition(marketId)
chainResult.fold(
@@ -124,101 +79,29 @@ class MarketPriceService(
when {
payout > BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
val price = BigDecimal.ONE
// 缓存已结算的结果
settledMarketCache.put(cacheKey, price)
return Pair(price, false)
return BigDecimal.ONE
}
payout == BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
val price = BigDecimal.ZERO
// 缓存已结算的结果
settledMarketCache.put(cacheKey, price)
return Pair(price, false)
return BigDecimal.ZERO
}
else -> {
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
Pair(null, false)
null
}
}
} else {
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
Pair(null, false) // 未结算的市场不缓存
null
}
},
onFailure = { e ->
// 检查是否是 execution reverted 错误
val isRpcError = e.message?.contains("execution reverted", ignoreCase = true) == true
if (isRpcError) {
logger.warn("链上查询市场条件出现 RPC 错误(execution reverted),可能市场不存在或尚未创建: marketId=$marketId, error=${e.message}")
} else {
logger.debug("链上查询市场条件失败,降级到 API 查询: marketId=$marketId, error=${e.message}")
}
Pair(null, isRpcError)
null
}
)
} catch (e: Exception) {
val isRpcError = e.message?.contains("execution reverted", ignoreCase = true) == true
if (isRpcError) {
logger.warn("链上查询市场条件异常(execution reverted: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
} else {
logger.debug("链上查询市场条件异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
}
Pair(null, isRpcError)
}
}
/**
* Gamma Market API 获取价格
* 使用 outcomePrices 字段格式通常为 JSON 字符串 "[\"0.5\", \"0.5\"]"
* 如果查询失败或 outcomePrices 为空返回 null
*/
private suspend fun getPriceFromGammaMarket(marketId: String, outcomeIndex: Int): BigDecimal? {
return try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(marketId))
if (!marketResponse.isSuccessful || marketResponse.body() == null) {
logger.debug("Gamma Market API 查询失败: marketId=$marketId, code=${marketResponse.code()}")
return null
}
val markets = marketResponse.body()!!
if (markets.isEmpty()) {
logger.debug("Gamma Market API 未找到市场: marketId=$marketId")
return null
}
val market = markets.first()
// 尝试从 outcomePrices 字段获取价格
val outcomePricesStr = market.outcomePrices
if (outcomePricesStr.isNullOrBlank()) {
logger.debug("Market outcomePrices 为空: marketId=$marketId")
return null
}
// 解析 outcomePrices(通常是 JSON 数组字符串)
val outcomePrices = try {
// 移除首尾的方括号和引号,按逗号分割
val cleanStr = outcomePricesStr.trim().removeSurrounding("[", "]")
cleanStr.split(",").map {
it.trim().removeSurrounding("\"").toSafeBigDecimal()
}
} catch (e: Exception) {
logger.warn("解析 outcomePrices 失败: marketId=$marketId, outcomePrices=$outcomePricesStr, error=${e.message}")
null
}
if (outcomePrices != null && outcomeIndex < outcomePrices.size) {
val price = outcomePrices[outcomeIndex]
logger.debug("从 Gamma Market API 获取价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$price")
return price
}
null
} catch (e: Exception) {
logger.debug("Gamma Market API 查询异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
null
}
}
@@ -324,29 +207,5 @@ class MarketPriceService(
}
}
/**
* 获取缓存统计信息
* 用于监控缓存命中率和性能
*/
fun getCacheStats(): String {
val stats = settledMarketCache.stats()
return """
已结算市场缓存统计:
- 缓存条目数: ${settledMarketCache.estimatedSize()}
- 命中次数: ${stats.hitCount()}
- 未命中次数: ${stats.missCount()}
- 命中率: ${"%.2f".format(stats.hitRate() * 100)}%
- 总请求次数: ${stats.requestCount()}
""".trimIndent()
}
/**
* 清空缓存测试或管理用
*/
fun clearSettledMarketCache() {
settledMarketCache.invalidateAll()
logger.info("已清空已结算市场缓存")
}
}
@@ -1,234 +0,0 @@
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
}
}
}
@@ -3,12 +3,12 @@ package com.wrbug.polymarketbot.service.common
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.ApiKeyResponse
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
@@ -19,6 +19,8 @@ import retrofit2.converter.gson.GsonConverterFactory
*/
@Service
class PolymarketApiKeyService(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String,
private val gson: Gson
) {
@@ -222,7 +224,7 @@ class PolymarketApiKeyService(
.build()
return Retrofit.Builder()
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
@@ -236,7 +238,7 @@ class PolymarketApiKeyService(
val okHttpClient = createClient().build()
return Retrofit.Builder()
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
@@ -401,37 +401,5 @@ class PolymarketClobService(
Result.failure(e)
}
}
/**
* 获取费率
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
*
* 注意根据 TypeScript clob-client 源码API 返回的字段名是 base_fee而不是文档中的 fee_rate_bps
* 参考: clob-client/src/client.ts:312
*
* @param tokenId Token ID
* @return 费率基点0 表示无费率1000 表示 10%
*/
suspend fun getFeeRate(tokenId: String): Result<Int> {
return try {
val response = clobApi.getFeeRate(tokenId)
if (response.isSuccessful && response.body() != null) {
val baseFee = response.body()!!.baseFee
logger.debug("获取费率成功: tokenId=$tokenId, baseFee=$baseFee")
Result.success(baseFee)
} else {
val errorBody = try {
response.errorBody()?.string()
} catch (e: Exception) {
null
}
logger.error("获取费率失败: tokenId=$tokenId, code=${response.code()}, errorBody=$errorBody")
Result.failure(Exception("获取费率失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取费率异常: tokenId=$tokenId, error=${e.message}", e)
Result.failure(e)
}
}
}
@@ -6,12 +6,9 @@ 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 com.wrbug.polymarketbot.util.DateUtils
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import org.springframework.stereotype.Service
import java.math.BigDecimal
@@ -21,9 +18,7 @@ import java.math.BigDecimal
@Service
class CopyTradingFilterService(
private val clobService: PolymarketClobService,
private val accountService: AccountService,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val jsonUtils: JsonUtils
private val accountService: AccountService
) {
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
@@ -35,8 +30,6 @@ class CopyTradingFilterService(
* @param tradePrice Leader 交易价格用于价格区间检查
* @param copyOrderAmount 跟单金额USDC用于仓位检查如果为null则不进行仓位检查
* @param marketId 市场ID用于仓位检查按市场过滤仓位
* @param marketTitle 市场标题用于关键字过滤
* @param marketEndDate 市场截止时间用于市场截止时间检查
* @return 过滤结果
*/
suspend fun checkFilters(
@@ -44,28 +37,9 @@ class CopyTradingFilterService(
tokenId: String,
tradePrice: BigDecimal? = null, // Leader 交易价格,用于价格区间检查
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
marketTitle: String? = null, // 市场标题,用于关键字过滤
marketEndDate: Long? = null, // 市场截止时间,用于市场截止时间检查
outcomeIndex: Int? = null // 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
marketId: String? = null // 市场ID,用于仓位检查(按市场过滤仓位)
): FilterResult {
// 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. 价格区间检查(如果配置了价格区间)
// 1. 价格区间检查(如果配置了价格区间
if (tradePrice != null) {
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
if (!priceRangeCheck.isPassed) {
@@ -73,24 +47,16 @@ class CopyTradingFilterService(
}
}
// 3. 检查是否需要获取订单簿或需要执行仓位检查
// 只有在配置了需要订单簿的过滤条件时才获取订单簿
// 2. 检查是否需要获取订单簿
// 只有在配置了需要订单簿的过滤条件时才获取
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
// 3.5. 如果不需要订单簿,则跳过订单簿相关的检查,但仍然需要检查仓位限制
if (!needOrderbook) {
// 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
if (copyOrderAmount != null && marketId != null) {
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
if (!positionCheck.isPassed) {
return positionCheck
}
}
// 通过所有检查
// 不需要订单簿,直接通过
return FilterResult.passed()
}
// 4. 获取订单簿(仅在需要时,只请求一次)
// 3. 获取订单簿(仅在需要时,只请求一次)
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (!orderbookResult.isSuccess) {
val error = orderbookResult.exceptionOrNull()
@@ -100,7 +66,7 @@ class CopyTradingFilterService(
val orderbook = orderbookResult.getOrNull()
?: return FilterResult.orderbookEmpty()
// 5. 买一卖一价差过滤(如果配置了)
// 4. 买一卖一价差过滤(如果配置了)
if (copyTrading.maxSpread != null) {
val spreadCheck = checkSpread(copyTrading, orderbook)
if (!spreadCheck.isPassed) {
@@ -108,7 +74,7 @@ class CopyTradingFilterService(
}
}
// 6. 订单深度过滤(如果配置了,检查所有方向)
// 5. 订单深度过滤(如果配置了,检查所有方向)
if (copyTrading.minOrderDepth != null) {
val depthCheck = checkOrderDepth(copyTrading, orderbook)
if (!depthCheck.isPassed) {
@@ -116,9 +82,9 @@ class CopyTradingFilterService(
}
}
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
// 6. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
if (copyOrderAmount != null && marketId != null) {
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
if (!positionCheck.isPassed) {
return positionCheck
}
@@ -127,65 +93,6 @@ 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 跟单配置
@@ -203,16 +110,12 @@ class CopyTradingFilterService(
// 检查最低价格
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
val minPriceStr = copyTrading.minPrice.stripTrailingZeros().toPlainString()
return FilterResult.priceRangeFailed("价格低于最低限制: $priceStr < $minPriceStr")
return FilterResult.priceRangeFailed("价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
}
// 检查最高价格
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
val maxPriceStr = copyTrading.maxPrice.stripTrailingZeros().toPlainString()
return FilterResult.priceRangeFailed("价格高于最高限制: $priceStr > $maxPriceStr")
return FilterResult.priceRangeFailed("价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
}
return FilterResult.passed()
@@ -250,9 +153,7 @@ class CopyTradingFilterService(
val spread = bestAsk.subtract(bestBid)
if (spread.gt(copyTrading.maxSpread)) {
val spreadStr = spread.stripTrailingZeros().toPlainString()
val maxSpreadStr = copyTrading.maxSpread.stripTrailingZeros().toPlainString()
return FilterResult.spreadFailed("价差过大: $spreadStr > $maxSpreadStr", orderbook)
return FilterResult.spreadFailed("价差过大: $spread > ${copyTrading.maxSpread}", orderbook)
}
return FilterResult.passed()
@@ -292,124 +193,83 @@ class CopyTradingFilterService(
val totalDepth = bidsDepth.add(asksDepth)
if (totalDepth.lt(copyTrading.minOrderDepth)) {
val totalDepthStr = totalDepth.stripTrailingZeros().toPlainString()
val minDepthStr = copyTrading.minOrderDepth.stripTrailingZeros().toPlainString()
return FilterResult.orderDepthFailed("订单深度不足: $totalDepthStr < $minDepthStr", orderbook)
return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
}
return FilterResult.passed()
}
/**
* 检查仓位限制按市场+方向检查
* 检查仓位限制按市场检查
* @param copyTrading 跟单配置
* @param copyOrderAmount 跟单金额USDC
* @param marketId 市场ID用于过滤该市场的仓位
* @param outcomeIndex 方向索引0, 1, 2, ...用于按市场+方向检查仓位
* @return 过滤结果
*/
private suspend fun checkPositionLimits(
copyTrading: CopyTrading,
copyOrderAmount: BigDecimal,
marketId: String,
outcomeIndex: Int?
marketId: String
): FilterResult {
// 如果未配置仓位限制,直接通过
if (copyTrading.maxPositionValue == null) {
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
return FilterResult.passed()
}
try {
// 获取账户的所有仓位信息
val positionsResult = accountService.getAllPositions()
if (positionsResult.isFailure) {
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${positionsResult.exceptionOrNull()?.message}")
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, error=${positionsResult.exceptionOrNull()?.message}")
// 如果获取仓位失败,为了安全起见,不通过检查
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
}
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
// 过滤出当前账户且该市场的仓位
val marketPositions = positions.currentPositions.filter {
val marketPositions = positions.currentPositions.filter {
it.accountId == copyTrading.accountId && it.marketId == marketId
}
// 检查最大仓位金额(如果配置了)
if (copyTrading.maxPositionValue != null && outcomeIndex != null) {
// 按市场+方向(outcomeIndex)分别计算数据库成本价
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarketAndOutcomeIndex(
copyTrading.id!!, marketId, outcomeIndex
) ?: BigDecimal.ZERO
// 外部持仓也需要按方向过滤,但由于外部持仓可能没有 outcomeIndex 信息,这里保守处理:
// 如果外部持仓存在,取该市场的所有外部持仓市值(与数据库取最大值)
val extValue = if (marketPositions.isNotEmpty()) {
marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
} else {
BigDecimal.ZERO
if (copyTrading.maxPositionValue != null) {
// 计算该市场的当前仓位总价值(累加该市场所有仓位的 currentValue
val currentPositionValue = marketPositions.sumOf { position ->
position.currentValue.toSafeBigDecimal()
}
// 取数据库值和外部持仓值的最大值
val currentPositionValue = dbValue.max(extValue)
// 检查:该市场该方向的当前仓位 + 跟单金额 <= 最大仓位金额
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
val currentValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
val dbValueStr = dbValue.stripTrailingZeros().toPlainString()
val extValueStr = extValue.stripTrailingZeros().toPlainString()
val orderAmountStr = copyOrderAmount.stripTrailingZeros().toPlainString()
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
val maxValueStr = copyTrading.maxPositionValue.stripTrailingZeros().toPlainString()
return FilterResult.maxPositionValueFailed(
"超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentValueStr} USDC (DB=${dbValueStr}, Ext=${extValueStr}), 跟单金额=${orderAmountStr} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC"
"超过最大仓位金额限制: 当前该市场仓位=${currentPositionValue} USDC, 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
)
}
}
// 检查最大仓位数量(如果配置了)
if (copyTrading.maxPositionCount != null) {
// 计算该市场的当前仓位数量(该市场不同方向的仓位算不同仓位)
val currentPositionCount = marketPositions.size
// 检查:该市场的当前仓位数量 <= 最大仓位数量
// 注意:如果该市场已有仓位,跟单可能会增加新的仓位(不同方向)或增加现有仓位
// 为了简化,我们检查当前该市场的仓位数量是否已经达到或超过限制
if (currentPositionCount >= copyTrading.maxPositionCount) {
return FilterResult.maxPositionCountFailed(
"超过最大仓位数量限制: 当前该市场仓位数量=${currentPositionCount} >= 最大限制=${copyTrading.maxPositionCount}"
)
}
}
return FilterResult.passed()
} catch (e: Exception) {
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, error=${e.message}", e)
// 如果检查异常,为了安全起见,不通过检查
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) {
val remainingTimeFormatted = DateUtils.formatDuration(remainingTime)
val maxLimitFormatted = DateUtils.formatDuration(copyTrading.maxMarketEndDate)
return FilterResult.marketEndDateFailed(
"市场截止时间超出限制: 剩余时间=${remainingTimeFormatted} > 最大限制=${maxLimitFormatted}"
)
}
return FilterResult.passed()
}
}
@@ -9,13 +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.IllegalBigDecimal
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
@@ -29,27 +24,11 @@ class CopyTradingService(
private val accountRepository: AccountRepository,
private val templateRepository: CopyTradingTemplateRepository,
private val leaderRepository: LeaderRepository,
private val monitorService: CopyTradingMonitorService,
private val jsonUtils: JsonUtils,
private val gson: Gson
) : ApplicationContextAware {
private val monitorService: CopyTradingMonitorService
) {
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
private var applicationContext: ApplicationContext? = null
override fun setApplicationContext(applicationContext: ApplicationContext) {
this.applicationContext = applicationContext
}
/**
* 获取代理对象用于解决 @Transactional 自调用问题
*/
private fun getSelf(): CopyTradingService {
return applicationContext?.getBean(CopyTradingService::class.java)
?: throw IllegalStateException("ApplicationContext not initialized")
}
/**
* 创建跟单配置
* 支持两种方式
@@ -67,7 +46,16 @@ class CopyTradingService(
val leader = leaderRepository.findById(request.leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// 3. 验证配置名(强校验:不能为空字符串
// 3. 检查是否已存在相同的跟单关系(accountId + leaderId
val existing = copyTradingRepository.findByAccountIdAndLeaderId(
request.accountId,
request.leaderId
)
if (existing != null) {
return Result.failure(IllegalArgumentException("该跟单关系已存在"))
}
// 4. 验证配置名(强校验:不能为空字符串)
val configName = request.configName?.trim()
if (configName.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("配置名不能为空"))
@@ -100,10 +88,7 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate,
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders
maxPositionCount = request.maxPositionCount
)
} else {
// 手动输入(所有字段必须提供)
@@ -131,10 +116,7 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal(),
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate,
pushFilteredOrders = request.pushFilteredOrders ?: false // 手动输入时使用请求中的值,默认为 false
maxPositionCount = request.maxPositionCount
)
}
@@ -162,12 +144,9 @@ class CopyTradingService(
minPrice = config.minPrice,
maxPrice = config.maxPrice,
maxPositionValue = config.maxPositionValue,
keywordFilterMode = config.keywordFilterMode,
keywords = config.keywords,
maxPositionCount = config.maxPositionCount,
configName = configName,
pushFailedOrders = request.pushFailedOrders ?: false,
maxMarketEndDate = config.maxMarketEndDate,
pushFilteredOrders = config.pushFilteredOrders
pushFailedOrders = request.pushFailedOrders ?: false
)
val saved = copyTradingRepository.save(copyTrading)
@@ -228,78 +207,14 @@ class CopyTradingService(
websocketReconnectInterval = request.websocketReconnectInterval ?: copyTrading.websocketReconnectInterval,
websocketMaxRetries = request.websocketMaxRetries ?: copyTrading.websocketMaxRetries,
supportSell = request.supportSell ?: copyTrading.supportSell,
// 处理可选字段:空字符串表示要清空(设置为 null),null 表示不更新,转换失败保留旧值
minOrderDepth = if (request.minOrderDepth != null) {
if (request.minOrderDepth.isBlank()) {
null
} else {
val converted = request.minOrderDepth.toSafeBigDecimal()
if (converted == IllegalBigDecimal) copyTrading.minOrderDepth else converted
}
} else {
copyTrading.minOrderDepth
},
maxSpread = if (request.maxSpread != null) {
if (request.maxSpread.isBlank()) {
null
} else {
val converted = request.maxSpread.toSafeBigDecimal()
if (converted == IllegalBigDecimal) copyTrading.maxSpread else converted
}
} else {
copyTrading.maxSpread
},
minPrice = if (request.minPrice != null) {
if (request.minPrice.isBlank()) {
null
} else {
val converted = request.minPrice.toSafeBigDecimal()
if (converted == IllegalBigDecimal) copyTrading.minPrice else converted
}
} else {
copyTrading.minPrice
},
maxPrice = if (request.maxPrice != null) {
if (request.maxPrice.isBlank()) {
null
} else {
val converted = request.maxPrice.toSafeBigDecimal()
if (converted == IllegalBigDecimal) copyTrading.maxPrice else converted
}
} else {
copyTrading.maxPrice
},
maxPositionValue = if (request.maxPositionValue != null) {
if (request.maxPositionValue.isBlank()) {
null
} else {
val converted = request.maxPositionValue.toSafeBigDecimal()
if (converted == IllegalBigDecimal) copyTrading.maxPositionValue else converted
}
} else {
copyTrading.maxPositionValue
},
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
},
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue,
maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount,
configName = configName,
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
pushFilteredOrders = request.pushFilteredOrders ?: copyTrading.pushFilteredOrders,
// 处理 maxMarketEndDate-1 表示要清空(设置为 null),null 表示不更新
maxMarketEndDate = if (request.maxMarketEndDate != null) {
if (request.maxMarketEndDate == -1L) {
null
} else {
request.maxMarketEndDate
}
} else {
copyTrading.maxMarketEndDate
},
updatedAt = System.currentTimeMillis()
)
@@ -334,7 +249,7 @@ class CopyTradingService(
*/
@Transactional
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
return getSelf().updateCopyTrading(
return updateCopyTrading(
CopyTradingUpdateRequest(
copyTradingId = request.copyTradingId,
enabled = request.enabled
@@ -349,10 +264,11 @@ class CopyTradingService(
return try {
val copyTradings = when {
request.accountId != null && request.leaderId != null -> {
copyTradingRepository.findByAccountIdAndLeaderId(
val found = copyTradingRepository.findByAccountIdAndLeaderId(
request.accountId,
request.leaderId
)
if (found != null) listOf(found) else emptyList()
}
request.accountId != null -> {
copyTradingRepository.findByAccountId(request.accountId)
@@ -507,47 +423,14 @@ class CopyTradingService(
minPrice = copyTrading.minPrice?.toPlainString(),
maxPrice = copyTrading.maxPrice?.toPlainString(),
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
keywordFilterMode = copyTrading.keywordFilterMode,
keywords = convertJsonToKeywords(copyTrading.keywords),
maxPositionCount = copyTrading.maxPositionCount,
configName = copyTrading.configName,
pushFailedOrders = copyTrading.pushFailedOrders,
pushFilteredOrders = copyTrading.pushFilteredOrders,
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 实体
*/
@@ -571,9 +454,6 @@ class CopyTradingService(
val minPrice: BigDecimal?,
val maxPrice: BigDecimal?,
val maxPositionValue: BigDecimal?,
val keywordFilterMode: String,
val keywords: String?, // JSON 字符串
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
val pushFilteredOrders: Boolean // 推送已过滤订单(默认关闭)
val maxPositionCount: Int?
)
}
@@ -20,10 +20,8 @@ enum class FilterStatus {
FAILED_ORDER_DEPTH,
/** 失败:超过最大仓位金额 */
FAILED_MAX_POSITION_VALUE,
/** 失败:关键字过滤 */
FAILED_KEYWORD_FILTER,
/** 失败:市场截止时间超出限制 */
FAILED_MARKET_END_DATE
/** 失败:超过最大仓位数量 */
FAILED_MAX_POSITION_COUNT
}
/**
@@ -85,16 +83,10 @@ data class FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_VALUE,
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,
/** 超过最大仓位数量 */
fun maxPositionCountFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
reason = reason
)
}
@@ -3,15 +3,12 @@ package com.wrbug.polymarketbot.service.copytrading.leaders
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.util.CategoryValidator
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import kotlinx.coroutines.runBlocking
/**
* Leader 管理服务
@@ -20,11 +17,9 @@ import kotlinx.coroutines.runBlocking
class LeaderService(
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val copyTradingRepository: CopyTradingRepository,
private val backtestTaskRepository: BacktestTaskRepository,
private val blockchainService: BlockchainService
private val copyTradingRepository: CopyTradingRepository
) {
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
/**
@@ -159,8 +154,7 @@ class LeaderService(
val leaderDtos = leaders.map { leader ->
val copyTradingCount = copyTradingRepository.countByLeaderId(leader.id!!)
val backtestCount = backtestTaskRepository.findByLeaderId(leader.id).size.toLong()
toDto(leader, copyTradingCount, backtestCount)
toDto(leader, copyTradingCount)
}
Result.success(
@@ -182,54 +176,19 @@ class LeaderService(
return try {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId)
val backtestCount = backtestTaskRepository.findByLeaderId(leaderId).size.toLong()
Result.success(toDto(leader, copyTradingCount, backtestCount))
Result.success(toDto(leader, copyTradingCount))
} catch (e: Exception) {
logger.error("查询 Leader 详情失败", e)
Result.failure(e)
}
}
/**
* 查询 Leader 余额
* 使用代理地址查询 USDC 余额和持仓信息
*/
fun getLeaderBalance(leaderId: Long): Result<LeaderBalanceResponse> {
return try {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// Leader 的 leaderAddress 就是代理地址
val walletAddress = leader.leaderAddress
// 使用通用方法查询余额
val balanceResult = runBlocking {
blockchainService.getWalletBalance(walletAddress)
}
balanceResult.map { walletBalance: WalletBalanceResponse ->
LeaderBalanceResponse(
leaderId = leader.id!!,
leaderAddress = leader.leaderAddress,
leaderName = leader.leaderName,
availableBalance = walletBalance.availableBalance,
positionBalance = walletBalance.positionBalance,
totalBalance = walletBalance.totalBalance,
positions = walletBalance.positions
)
}
} catch (e: Exception) {
logger.error("查询 Leader 余额失败", e)
Result.failure(e)
}
}
/**
* 转换为 DTO
*/
private fun toDto(leader: Leader, copyTradingCount: Long = 0, backtestCount: Long = 0): LeaderDto {
private fun toDto(leader: Leader, copyTradingCount: Long = 0): LeaderDto {
return LeaderDto(
id = leader.id!!,
leaderAddress = leader.leaderAddress,
@@ -238,7 +197,6 @@ class LeaderService(
remark = leader.remark,
website = leader.website,
copyTradingCount = copyTradingCount,
backtestCount = backtestCount,
createdAt = leader.createdAt,
updatedAt = leader.updatedAt
)
@@ -114,10 +114,10 @@ class AccountOnChainMonitorService(
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result.isJsonNull) {
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
return
}
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
@@ -14,9 +14,7 @@ import org.springframework.stereotype.Service
/**
* 跟单监听服务主服务
* 管理所有Leader的交易监听
* 使用双重监听机制
* 1. Activity WebSocket - 低延迟< 100ms优先使用
* 2. OnChain WebSocket - 高可靠性~2-3s作为兜底
* 同时运行链上 WebSocket 监听和轮询监听并行处理
* 同时监听跟单账户的卖出/赎回事件通过链上 WebSocket
*/
@Service
@@ -24,7 +22,7 @@ class CopyTradingMonitorService(
private val copyTradingRepository: CopyTradingRepository,
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val activityWsService: PolymarketActivityWsService,
private val pollingService: CopyTradingPollingService,
private val onChainWsService: OnChainWsService,
private val accountOnChainMonitorService: AccountOnChainMonitorService
) {
@@ -53,17 +51,15 @@ class CopyTradingMonitorService(
@PreDestroy
fun destroy() {
scope.cancel()
// 停止所有监听
activityWsService.stop()
// 停止轮询和链上 WS 监听
pollingService.stop()
onChainWsService.stop()
accountOnChainMonitorService.stop()
}
/**
* 启动监听
* 启动双重监听机制
* 1. Activity WebSocket - 低延迟< 100ms优先使用
* 2. OnChain WebSocket - 高可靠性~2-3s作为兜底
* 同时启动链上 WebSocket 监听和轮询监听并行运行
* 同时启动跟单账户的链上 WebSocket 监听用于检测卖出/赎回事件
*/
suspend fun startMonitoring() {
@@ -86,13 +82,14 @@ class CopyTradingMonitorService(
accountRepository.findById(accountId).orElse(null)
}
// 4. 启动 Activity WebSocket 监听(优先,低延迟
activityWsService.start(leaders)
// 5. 启动链上 WebSocket 监听(兜底,高可靠性)
// 4. 同时启动链上 WebSocket 监听和轮询监听(并行运行
// 链上 WS 监听 Leader 的交易(实时,秒级延迟)
onChainWsService.start(leaders)
// 6. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件
// 轮询监听 Leader 的交易(延迟,2秒间隔,作为备份
pollingService.start(leaders)
// 5. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
accountOnChainMonitorService.start(accounts)
}
@@ -109,9 +106,9 @@ class CopyTradingMonitorService(
return
}
// 同时添加到两种监听
activityWsService.addLeader(leader)
// 同时添加到链上 WS 监听和轮询监听(如果不在列表中才添加)
onChainWsService.addLeader(leader)
pollingService.addLeader(leader)
}
/**
@@ -125,9 +122,9 @@ class CopyTradingMonitorService(
return
}
// 没有启用的跟单配置了,同时从两种监听移除
activityWsService.removeLeader(leaderId)
// 没有启用的跟单配置了,移除监听
onChainWsService.removeLeader(leaderId)
pollingService.removeLeader(leaderId)
}
/**
@@ -141,8 +138,8 @@ class CopyTradingMonitorService(
if (copyTradings.isNotEmpty()) {
// 有启用的跟单配置,确保在监听列表中
activityWsService.addLeader(leader)
onChainWsService.addLeader(leader)
pollingService.addLeader(leader)
// 更新账户监听(添加该配置关联的账户)
val accountIds = copyTradings.map { it.accountId }.distinct()
@@ -153,9 +150,9 @@ class CopyTradingMonitorService(
}
}
} else {
// 没有启用的跟单配置,同时从两种监听移除
activityWsService.removeLeader(leaderId)
// 没有启用的跟单配置,移除监听
onChainWsService.removeLeader(leaderId)
pollingService.removeLeader(leaderId)
}
}
@@ -184,8 +181,8 @@ class CopyTradingMonitorService(
*/
suspend fun restartMonitoring() {
// 停止所有监听
activityWsService.stop()
onChainWsService.stop()
pollingService.stop()
delay(1000) // 等待1秒
startMonitoring()
}
@@ -0,0 +1,279 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.api.UserActivityResponse
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import org.springframework.stereotype.Service
import retrofit2.Response
import java.util.concurrent.ConcurrentHashMap
/**
* 跟单轮询监听服务
* 通过定期轮询 Polymarket Data API /activity 接口获取Leader的交易记录
* 使用 /activity 接口可以查询用户的链上活动包括交易
*/
@Service
class CopyTradingPollingService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val retrofitFactory: RetrofitFactory,
private val templateRepository: CopyTradingTemplateRepository
) {
private val logger = LoggerFactory.getLogger(CopyTradingPollingService::class.java)
@Value("\${copy.trading.polling.interval:2000}")
private var pollingInterval: Long = 2000 // 轮询间隔(毫秒),默认2秒
@Value("\${copy.trading.polling.enabled:true}")
private var pollingEnabled: Boolean = true // 是否启用轮询
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 存储需要监听的LeaderleaderId -> Leader
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
// 存储每个Leader已缓存的交易ID集合:leaderId -> Set<tradeId>
private val cachedTradeIds = ConcurrentHashMap<Long, MutableSet<String>>()
// 存储每个Leader是否首次轮询:leaderId -> isFirstPoll
private val isFirstPoll = ConcurrentHashMap<Long, Boolean>()
// 轮询任务
private var pollingJob: Job? = null
/**
* 启动轮询监听
*/
fun start(leaders: List<Leader>) {
if (!pollingEnabled) {
return
}
leaders.forEach { leader ->
addLeader(leader)
}
// 启动轮询任务
startPolling()
}
/**
* 添加Leader监听
*/
fun addLeader(leader: Leader) {
if (leader.id == null) {
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
return
}
val leaderId = leader.id
monitoredLeaders[leaderId] = leader
// 初始化缓存的交易ID集合
cachedTradeIds[leaderId] = mutableSetOf()
// 首次轮询标志,用于缓存数据而不处理
isFirstPoll[leaderId] = true
// 如果轮询任务没有运行,启动它
startPolling()
}
/**
* 移除Leader监听
*/
fun removeLeader(leaderId: Long) {
monitoredLeaders.remove(leaderId)
cachedTradeIds.remove(leaderId)
isFirstPoll.remove(leaderId)
// 如果没有需要监听的Leader了,停止轮询任务
if (monitoredLeaders.isEmpty()) {
stopPolling()
}
}
/**
* 停止所有监听
*/
fun stop() {
stopPolling()
monitoredLeaders.clear()
cachedTradeIds.clear()
isFirstPoll.clear()
}
/**
* 启动轮询任务
*/
private fun startPolling() {
if (pollingJob != null && pollingJob!!.isActive) {
return
}
if (monitoredLeaders.isEmpty()) {
return
}
pollingJob = scope.launch {
while (isActive) {
try {
// 轮询所有Leader的交易
pollAllLeaders()
// 等待下一次轮询
delay(pollingInterval)
} catch (e: Exception) {
logger.error("轮询任务异常", e)
delay(pollingInterval) // 异常后继续等待
}
}
}
}
/**
* 停止轮询任务
*/
private fun stopPolling() {
pollingJob?.cancel()
pollingJob = null
}
/**
* 轮询所有Leader的交易
*/
private suspend fun pollAllLeaders() {
val leaders = monitoredLeaders.values.toList()
if (leaders.isEmpty()) {
return
}
// 并发轮询所有Leader(限制并发数)
leaders.chunked(10).forEach { chunk ->
chunk.forEach { leader ->
try {
pollLeaderTrades(leader)
} catch (e: Exception) {
logger.error("轮询Leader交易失败: leaderId=${leader.id}, address=${leader.leaderAddress}", e)
}
}
// 每个chunk之间稍作延迟,避免API限流
delay(100)
}
}
/**
* 轮询单个Leader的交易
* 使用 Polymarket Data API /activity 接口
* 通过 diff 分析增量数据不使用 start 字段
*/
private suspend fun pollLeaderTrades(leader: Leader) {
if (leader.id == null) {
return
}
val leaderId = leader.id
val leaderAddress = leader.leaderAddress
try {
val firstPoll = isFirstPoll[leaderId] == true
val cachedIds = cachedTradeIds[leaderId] ?: mutableSetOf()
// 创建 Data API 客户端(不需要认证)
val dataApi = retrofitFactory.createDataApi()
// 查询用户活动(只查询交易类型,不使用 start 字段)
// 查询最近的数据(limit=100),通过 diff 找出增量
val response: Response<List<UserActivityResponse>> = dataApi.getUserActivity(
user = leaderAddress,
limit = 100, // 每次最多查询100条
offset = 0,
type = listOf("TRADE"), // 只查询交易类型
start = null, // 不使用 start 字段
sortBy = "TIMESTAMP",
sortDirection = "DESC" // 按时间戳降序,最新的在前
)
if (!response.isSuccessful || response.body() == null) {
logger.warn("获取Leader活动失败: leaderId=$leaderId, address=$leaderAddress, code=${response.code()}, message=${response.message()}")
return
}
val activities = response.body()!!
// 将 UserActivityResponse 转换为 TradeResponse
val allTrades = activities.mapNotNull { activity ->
// 只处理交易类型
if (activity.type != "TRADE" || activity.side == null || activity.price == null || activity.size == null) {
return@mapNotNull null
}
// 转换为 TradeResponse
TradeResponse(
id = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}",
market = activity.conditionId,
side = activity.side, // BUY 或 SELL
price = activity.price.toString(),
size = activity.size.toString(),
timestamp = activity.timestamp.toString(), // 时间戳(秒)
user = activity.proxyWallet,
outcomeIndex = activity.outcomeIndex, // 结果索引(0=YES, 1=NO
outcome = activity.outcome // 结果名称
)
}
if (firstPoll) {
// 首次轮询:缓存所有查询到的交易ID,不处理
val tradeIds = allTrades.map { it.id }.toSet()
cachedIds.addAll(tradeIds)
cachedTradeIds[leaderId] = cachedIds
// 标记首次轮询完成
isFirstPoll[leaderId] = false
} else {
// 后续轮询:通过 diff 找出新增的交易
val newTradeIds = allTrades.map { it.id }.toSet()
val incrementalTradeIds = newTradeIds - cachedIds
if (incrementalTradeIds.isNotEmpty()) {
// 找出新增的交易
val incrementalTrades = allTrades.filter { it.id in incrementalTradeIds }
// 处理新增的交易
incrementalTrades.forEach { trade ->
try {
// 检查是否已处理(去重由processTrade内部处理)
copyOrderTrackingService.processTrade(leaderId, trade, "polling")
} catch (e: Exception) {
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
}
}
// 更新缓存:添加新增的交易ID
cachedIds.addAll(incrementalTradeIds)
cachedTradeIds[leaderId] = cachedIds
} else {
}
// 限制缓存大小,避免内存溢出(只保留最近1000条)
if (cachedIds.size > 1000) {
// 保留最新的1000条(由于查询是按时间戳降序,保留前1000条即可)
val sortedTradeIds = allTrades.map { it.id }.take(1000).toSet()
cachedTradeIds[leaderId] = sortedTradeIds.toMutableSet()
}
}
} catch (e: Exception) {
logger.error("轮询Leader交易异常: leaderId=$leaderId, address=$leaderAddress", e)
}
}
}
@@ -9,8 +9,8 @@ import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import com.wrbug.polymarketbot.constants.PolymarketConstants
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
@@ -28,7 +28,8 @@ class CopyTradingWebSocketService(
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
private val websocketUrl: String = PolymarketConstants.USER_WS_URL
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
@@ -213,7 +214,7 @@ class CopyTradingWebSocketService(
// 处理交易
scope.launch {
try {
copyOrderTrackingService.processTrade(leaderId, trade, "activity-ws")
copyOrderTrackingService.processTrade(leaderId, trade, "websocket")
} catch (e: Exception) {
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
}
@@ -1,8 +1,5 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.google.gson.JsonNull
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.LeaderRepository
@@ -14,7 +11,6 @@ import okhttp3.OkHttpClient
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/**
* 链上 WebSocket 监听服务
@@ -27,17 +23,12 @@ class OnChainWsService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val leaderRepository: LeaderRepository
) {
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
// 存储需要监听的LeaderleaderId -> Leader
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
.maximumSize(100)
.build()
/**
* 启动链上 WebSocket 监听
* 通过统一服务订阅所有 Leader
@@ -49,14 +40,14 @@ class OnChainWsService(
stop()
return
}
// 更新 Leader 列表
monitoredLeaders.clear()
leaders.forEach { leader ->
addLeader(leader)
}
}
/**
* 添加Leader监听
* 通过统一服务订阅该 Leader 的地址
@@ -66,17 +57,17 @@ class OnChainWsService(
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
return
}
val leaderId = leader.id!!
// 如果已经在监听列表中,不重复添加
if (monitoredLeaders.containsKey(leaderId)) {
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
return
}
monitoredLeaders[leaderId] = leader
// 通过统一服务订阅
val subscriptionId = "LEADER_$leaderId"
unifiedOnChainWsService.subscribe(
@@ -88,53 +79,36 @@ class OnChainWsService(
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
}
)
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
}
/**
* 处理 Leader 的交易
*/
private suspend fun handleLeaderTransaction(
leaderId: Long,
txHash: String,
httpClient: OkHttpClient,
rpcApi: EthereumRpcApi
) {
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
val leader = monitoredLeaders[leaderId] ?: return
// 根据 txHash 去重(使用原子操作避免竞态条件)
val currentTime = System.currentTimeMillis()
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
if (existingTimestamp != null) {
logger.debug("交易已处理过,跳过: leaderId=$leaderId, txHash=$txHash, firstProcessedAt=$existingTimestamp")
return
}
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
try {
// 获取交易 receipt
val receiptRequest = JsonRpcRequest(
method = "eth_getTransactionReceipt",
params = listOf(txHash)
)
val receiptResponse = rpcApi.call(receiptRequest)
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
return
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result is JsonNull) {
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
return
}
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
// 获取区块号和时间戳
val blockNumber = receiptJson.get("blockNumber")?.asString
val blockTimestamp = if (blockNumber != null) {
@@ -142,15 +116,11 @@ class OnChainWsService(
} else {
null
}
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: run {
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
return
}
val logs = receiptJson.getAsJsonArray("logs") ?: return
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
// 解析交易信息
val trade = OnChainWsUtils.parseTradeFromTransfers(
txHash = txHash,
@@ -160,37 +130,34 @@ class OnChainWsService(
erc1155Transfers = erc1155Transfers,
retrofitFactory = retrofitFactory
)
if (trade != null) {
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
// 调用 processTrade 处理交易
copyOrderTrackingService.processTrade(
leaderId = leaderId,
trade = trade,
source = "onchain-ws"
)
} else {
logger.warn("无法解析交易(返回 null: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
}
} catch (e: Exception) {
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
}
}
/**
* 移除Leader监听
* 取消该 Leader 的订阅
*/
fun removeLeader(leaderId: Long) {
monitoredLeaders.remove(leaderId)
// 通过统一服务取消订阅
val subscriptionId = "LEADER_$leaderId"
unifiedOnChainWsService.unsubscribe(subscriptionId)
logger.info("移除 Leader 监听: leaderId=$leaderId")
}
/**
* 停止监听
*/
@@ -202,7 +169,7 @@ class OnChainWsService(
}
monitoredLeaders.clear()
}
@PreDestroy
fun destroy() {
stop()
@@ -207,7 +207,6 @@ object OnChainWsUtils {
usdcRaw = usdcIn
} else {
// 无法判断交易方向
logger.debug("无法判断交易方向: txHash=$txHash, bestInId=$bestInId, bestInVal=$bestInVal, bestOutId=$bestOutId, bestOutVal=$bestOutVal, usdcOut=$usdcOut, usdcIn=$usdcIn")
return null
}
@@ -1,576 +0,0 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
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.constants.PolymarketConstants
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/**
* Polymarket Activity WebSocket 监听服务
* 通过订阅全局 activity 交易流trades + orders_matched客户端过滤 Leader 地址实现实时交易检测
* 延迟 < 100ms适合快速跟单场景
*/
@Service
class PolymarketActivityWsService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val leaderRepository: LeaderRepository
) {
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
private val websocketUrl: String = PolymarketConstants.ACTIVITY_WS_URL
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 单例 WebSocket 客户端
private var wsClient: PolymarketWebSocketClient? = null
// 要监听的 Leader 地址集合(小写地址 -> leaderId
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
// 因为同时订阅 trades 和 orders_matched,同一个交易可能被推送两次
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build()
// 是否已订阅
@Volatile
private var isSubscribed = false
// 最后一次收到 activity 消息的时间(毫秒时间戳)
@Volatile
private var lastActivityTime: Long = 0
// Activity 消息超时检测任务
private var activityTimeoutJob: Job? = null
// 性能统计
private var totalMessagesProcessed = 0L
private var addressMatchMessages = 0L
private var jsonParseMessages = 0L
private var duplicateTxHashMessages = 0L
/**
* 启动监听
*/
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 监听(trades + orders_matched),监控 ${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"
* 同时订阅 trades orders_matched 两种类型
*/
private fun subscribeAllActivity() {
val client = wsClient
if (client == null || !client.isConnected()) {
logger.warn("WebSocket 未连接,无法订阅")
return
}
try {
// 根据 real-time-data-client 的协议格式
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
// 同时订阅 trades 和 orders_matched 两种类型
val subscribeMessage = """
{
"action": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
},
{
"topic": "activity",
"type": "orders_matched"
}
]
}
""".trimIndent()
client.sendMessage(subscribeMessage)
isSubscribed = true
// 重置最后一次收到 activity 消息的时间
lastActivityTime = System.currentTimeMillis()
// 启动 Activity 消息超时检测
// startActivityTimeoutCheck()
logger.info("Activity WebSocket 订阅成功(全局交易流: trades + orders_matched")
} catch (e: Exception) {
logger.error("订阅 Activity WebSocket 失败", e)
isSubscribed = false
}
}
/**
* 启动 Activity 消息超时检测
* 每30秒检查一次如果超过30秒没有收到activity消息则重连
*/
private fun startActivityTimeoutCheck() {
// 先停止之前的检测任务
stopActivityTimeoutCheck()
activityTimeoutJob = scope.launch {
while (isActive && isSubscribed) {
delay(30000) // 每30秒检查一次
// 如果已经取消订阅,停止检测
if (!isSubscribed) {
break
}
// 如果 lastActivityTime 为 0,说明还没有收到过消息,跳过本次检测
if (lastActivityTime == 0L) {
continue
}
val currentTime = System.currentTimeMillis()
val timeSinceLastActivity = currentTime - lastActivityTime
// 如果超过30秒没有收到activity消息,触发重连
if (timeSinceLastActivity >= 30000) {
logger.warn("超过30秒未收到 Activity 消息,触发重连。距离上次消息: ${timeSinceLastActivity}ms")
// 关闭当前连接并重连
wsClient?.closeConnection()
wsClient = null
isSubscribed = false
// 重新连接
connectAndSubscribe()
break // 重连后会重新启动检测任务
}
}
}
}
/**
* 停止 Activity 消息超时检测
*/
private fun stopActivityTimeoutCheck() {
activityTimeoutJob?.cancel()
activityTimeoutJob = null
}
/**
* 检查消息是否包含监听的 Leader 地址
* 快速过滤避免不必要的 JSON 解析
* 只需要检查 "proxyWallet":"0x..." "trader":{"address":"0x..."} 格式
*/
private fun containsMonitoredAddress(message: String): Boolean {
// 快速检查:如果消息很短,不可能包含地址
if (message.length < 50) {
return false
}
// 遍历所有监听的地址
for ((address, leaderId) in monitoredAddresses) {
// 检查 proxyWallet:格式为 "proxyWallet":"0x..."
if (message.contains("\"proxyWallet\":\"$address\"", ignoreCase = true)) {
addressMatchMessages++
return true
}
// 检查 trader.address:格式为 "trader":{"address":"0x..."}
if (message.contains("\"trader\"", ignoreCase = true) &&
message.contains("\"address\":\"$address\"", ignoreCase = true)
) {
addressMatchMessages++
return true
}
}
return false
}
/**
* 处理消息
*/
private fun handleMessage(message: String) {
try {
totalMessagesProcessed++
// 处理 PONG 响应
if (message.trim() == "PONG" || message.trim() == "pong") {
return
}
// 快速预检查:检查是否包含监听地址
// 绝大部分消息会在这一步被过滤掉,避免不必要的 JSON 解析
if (!containsMonitoredAddress(message)) {
return
}
logger.info("发现leader交易:${message}")
// 使用扩展函数解析消息(只对包含监听地址的消息)
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
// 不是有效的 JSON 或格式不匹配,跳过
logger.warn("无法解析为 ActivityTradeMessage: ${message.take(200)}")
return
}
jsonParseMessages++
// 检查是否是 activity 消息(trades 或 orders_matched
if (tradeMessage.topic != "activity" ||
(tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) {
return
}
// 更新最后一次收到 activity 消息的时间(即使不是我们监听的 Leader 的交易)
lastActivityTime = System.currentTimeMillis()
val payload = tradeMessage.payload
// 根据 txHash 去重(使用原子操作避免竞态条件)
val txHash = payload.transactionHash
if (txHash != null && txHash.isNotBlank()) {
val currentTime = System.currentTimeMillis()
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
if (existingTimestamp != null) {
duplicateTxHashMessages++
logger.debug("交易已处理过,跳过: txHash=$txHash, firstProcessedAt=$existingTimestamp, type=${tradeMessage.type}")
return
}
}
// 提取交易者地址
val traderAddress = extractTraderAddress(payload) ?: run {
// 没有交易者地址,跳过
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
return
}
// 二次验证:确认地址匹配
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
* 支持 NumberStringBigDecimal 等类型
*/
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 监听")
stopActivityTimeoutCheck()
wsClient?.closeConnection()
wsClient = null
isSubscribed = false
monitoredAddresses.clear()
processedTxHashes.invalidateAll() // 清空去重缓存
lastActivityTime = 0
}
/**
* 获取连接状态
*/
fun isConnected(): Boolean {
return wsClient?.isConnected() ?: false
}
/**
* 获取监听的 Leader 数量
*/
fun getMonitoredCount(): Int {
return monitoredAddresses.size
}
/**
* 获取性能统计信息
*/
fun getPerformanceStats(): Map<String, Any> {
val jsonParseRate = if (totalMessagesProcessed > 0) {
(jsonParseMessages.toDouble() / totalMessagesProcessed * 100).toInt()
} else {
0
}
return mapOf(
"totalMessages" to totalMessagesProcessed,
"addressMatches" to addressMatchMessages,
"jsonParses" to jsonParseMessages,
"duplicateTxHashes" to duplicateTxHashMessages,
"jsonParseRate" to "$jsonParseRate%",
"filteringEfficiency" to if (totalMessagesProcessed > 0) {
((1.0 - jsonParseMessages.toDouble() / totalMessagesProcessed) * 100).toInt()
} else {
0
}
)
}
@PreDestroy
fun destroy() {
logger.info("Activity WS 性能统计: ${getPerformanceStats()}")
stop()
scope.cancel()
}
}

Some files were not shown because too many files have changed in this diff Show More