Initial commit: Polymarket copy trading bot

- Backend: Spring Boot + Kotlin implementation
  - Account management with private key import
  - Leader management
  - Copy trading configuration
  - Order synchronization
  - Balance and position queries via Polymarket API
  - Ethereum RPC integration for USDC balance
  - Proxy address calculation

- Frontend: React + TypeScript
  - Account management UI
  - Mobile responsive design
  - Account import with private key/mnemonic support
  - Balance display and account details modal

- Database: MySQL with Flyway migrations
- API Integration: Polymarket CLOB API, Data API, Ethereum RPC
This commit is contained in:
WrBug
2025-11-21 04:32:08 +08:00
commit 4f7fef145f
70 changed files with 14618 additions and 0 deletions
+506
View File
@@ -0,0 +1,506 @@
---
alwaysApply: true
path: backend/**
---
# 后端开发规范
## 代码完成规范
### TODO 处理规则
- **禁止**在代码中添加 TODO 注释
- **必须**根据 TODO 的内容直接完成代码实现
- 如果遇到暂时无法完全实现的功能,应该:
1. 实现一个可用的基础版本
2. 添加清晰的注释说明当前实现的限制和后续改进方向
3. 确保代码可以正常编译和运行
- **禁止**使用 `// TODO: 实现XXX` 这样的注释
- **禁止**使用 `// FIXME:` 或 `// XXX:` 这样的注释
- 如果某个功能需要依赖外部资源(如 API、库等),应该:
1. 先实现一个占位或模拟实现
2. 在注释中说明依赖关系和实现方式
3. 确保代码逻辑完整,不会因为未实现的功能而崩溃
### API 调用实现规则
- **必须**查找相关的 API 文档或接口定义
- **必须**根据 API 文档完成代码实现
- **禁止**在 API 调用处添加 TODO 注释
- **禁止**直接返回 mock 数据或硬编码的假数据
- **必须**调用真实的 API 或查询真实的数据库
- 如果 API 文档不完整,应该:
1. 查找项目中已有的类似 API 调用作为参考
2. 查看 API 接口定义(如 `PolymarketClobApi.kt`
3. 查看 API 文档(如 `docs/polymarket-api-reference.md`
4. 实现一个可用的版本,包含错误处理
- 如果 API 调用失败,应该:
1. 返回明确的错误信息
2. 记录错误日志
3. 返回错误响应,而不是返回 mock 数据
### 代码完成示例
```kotlin
// ❌ 错误:添加 TODO 注释
fun getAccountBalance(accountId: Long?): Result<AccountBalanceResponse> {
// TODO: 调用 Polymarket API 查询余额
return Result.success(AccountBalanceResponse(usdcBalance = "0"))
}
// ✅ 正确:查找 API 文档并完成实现
// 1. 查找 API 接口定义:PolymarketClobApi.getActiveOrders()
// 2. 查找 API 文档:docs/polymarket-api-reference.md
// 3. 实现完整的 API 调用逻辑,调用真实的 API
fun getAccountBalance(accountId: Long?): Result<AccountBalanceResponse> {
return try {
val account = getAccount(accountId) ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 如果账户没有配置 API 凭证,返回错误而不是 mock 数据
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
return Result.failure(IllegalStateException("账户未配置 API 凭证,无法查询余额"))
}
// 解密 API 凭证并创建认证客户端
val apiKey = cryptoUtils.decrypt(account.apiKey!!)
val apiSecret = cryptoUtils.decrypt(account.apiSecret!!)
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase!!)
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
// 调用真实的 API 查询余额
val response = clobApi.getActiveOrders(limit = 100)
if (response.isSuccessful && response.body() != null) {
// 根据 API 响应处理余额数据(从真实响应中解析)
val orders = response.body()!!
// 实际应该调用余额查询接口或从链上查询
// 这里只是示例,实际应该调用真实的余额查询 API
val balance = queryRealBalanceFromApi(clobApi, account.walletAddress)
Result.success(AccountBalanceResponse(usdcBalance = balance))
} else {
logger.error("查询余额失败: ${response.code()} ${response.message()}")
Result.failure(Exception("查询余额失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("查询账户余额失败", e)
Result.failure(e)
}
}
```
## 需求文档引用
### 跟单系统需求
- **需求文档**: `docs/copy-trading-requirements.md`
- **说明**: 所有跟单系统相关的功能实现必须严格按照需求文档执行
- **核心功能**:
- 账户管理(通过私钥导入,支持多账户)
- Leader 管理(被跟单者管理)
- 订单同步与执行(监控 Leader 交易并自动复制)
- 跟单配置管理(全局配置和单个 Leader 配置)
- 风险控制(每日亏损限制、订单数限制等)
- 跟单记录与统计
**重要提示**: 在实现跟单系统相关功能时,请先查阅 `docs/copy-trading-requirements.md` 了解详细需求,包括:
- 数据模型设计(Account、Leader、CopyOrder 等)
- API 接口设计(请求/响应格式)
- 业务规则和验证逻辑
- 安全要求(私钥加密存储、API Key 管理等)
## 项目范围
### 平台支持
- **仅支持**: 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,
@Column(name = "code", unique = true, nullable = false, length = 100)
val code: String = "",
@Column(name = "category", nullable = false, length = 20)
val category: String = "", // sports 或 crypto
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
val amount: BigDecimal = BigDecimal.ZERO,
@Column(name = "status", nullable = false, length = 20)
val status: String = "", // active, inactive
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
```
## 数值计算规范
### BigDecimal使用
- 所有数值计算必须使用 `BigDecimal`
- 使用 `String` 存储数值
- 使用扩展函数进行安全转换和比较
```kotlin
// 使用扩展函数
val amount = "0.5".toSafeBigDecimal()
val total = amount.add("0.4".toSafeBigDecimal())
// 比较
if (total.lt(BigDecimal.ONE)) {
// 业务逻辑
}
```
## 时间字段规范
### 时间戳使用
- 所有时间字段使用 `Long` 类型存储毫秒级时间戳
- **禁止**使用 `LocalDateTime` 或其他时间类型
```kotlin
// ✅ 正确
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
// ❌ 错误
@Column(name = "created_at")
val createdAt: LocalDateTime = LocalDateTime.now() // 禁止使用
```
## HTTP客户端规范
### Retrofit使用
- 使用 Retrofit 定义 API 接口
- 使用 OkHttp 作为底层 HTTP 客户端
- 使用拦截器处理认证
```kotlin
// Polymarket CLOB API接口定义(跟单系统需要)
interface PolymarketClobApi {
@POST("/orders")
suspend fun createOrder(@Body order: CreateOrderRequest): Response<OrderResponse>
@GET("/orders/active")
suspend fun getActiveOrders(
@Query("market") market: String?,
@Query("limit") limit: Int?,
@Query("offset") offset: Int?
): Response<List<OrderResponse>>
@DELETE("/orders/{orderId}")
suspend fun cancelOrder(@Path("orderId") orderId: String): Response<CancelOrderResponse>
@GET("/trades")
suspend fun getTrades(
@Query("market") market: String?,
@Query("user") user: String?,
@Query("limit") limit: Int?,
@Query("offset") offset: Int?
): Response<List<TradeResponse>>
}
// Retrofit配置
@Configuration
class RetrofitConfig {
@Bean
fun polymarketClobApi(): PolymarketClobApi {
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor())
.build()
return Retrofit.Builder()
.baseUrl("https://clob.polymarket.com")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(PolymarketClobApi::class.java)
}
}
```
## API接口规范
### 请求规范
- **所有接口统一使用POST方法**,包括查询类接口
- 请求头: `Content-Type: application/json`
- 请求体: JSON格式
### 响应规范
- **统一响应格式**:
```json
{
"code": 0,
"data": {},
"msg": ""
}
```
- **响应字段说明**:
- `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`: 认证/权限错误
- `3001-3999`: 资源不存在
- `4001-4999`: 业务逻辑错误
- `5001-5999`: 服务器内部错误
详细错误码定义参见需求文档
## 禁止事项
### 代码质量
- ❌ 禁止使用 `!!` 除非有明确原因
- ❌ 禁止忽略异常
- ❌ 禁止硬编码配置值
- ❌ 禁止提交敏感信息到Git
- ❌ Controller 方法禁止使用 `suspend`
- ❌ 实体类ID禁止使用 `Long = 0`
- ❌ **禁止直接返回 mock 数据或硬编码的假数据**
- ❌ **禁止在 API 调用失败时返回 mock 数据作为默认值**
- ❌ **所有返回的数据必须来自真实的 API 调用或数据库查询**
### 配置
- ❌ 禁止使用 `application.yml`
- ❌ 禁止在代码中硬编码配置值
### 类型
- ❌ 禁止使用 `Double` 进行数值计算
- ❌ 禁止使用 `LocalDateTime` 存储时间
- ❌ 禁止实体类ID使用非空默认值
### API接口
- ❌ 禁止使用GET、PUT、DELETE等方法(统一使用POST
- ❌ 禁止返回不符合统一格式的响应
- ❌ 禁止在响应中直接返回Map类型(使用data class
+258
View File
@@ -0,0 +1,258 @@
---
alwaysApply: true
path: frontend/**
---
# 前端开发规范
## 代码完成规范
### TODO 处理规则
- **禁止**在代码中添加 TODO 注释
- **必须**根据 TODO 的内容直接完成代码实现
- 如果遇到暂时无法完全实现的功能,应该:
1. 实现一个可用的基础版本
2. 添加清晰的注释说明当前实现的限制和后续改进方向
3. 确保代码可以正常编译和运行
- **禁止**使用 `// TODO: 实现XXX` 这样的注释
- **禁止**使用 `// FIXME:` 或 `// XXX:` 这样的注释
- 如果某个功能需要依赖外部资源(如 API、库等),应该:
1. 先实现一个占位或模拟实现
2. 在注释中说明依赖关系和实现方式
3. 确保代码逻辑完整,不会因为未实现的功能而崩溃
### API 调用实现规则
- **必须**查找相关的 API 文档或接口定义
- **必须**根据 API 文档完成代码实现
- **禁止**在 API 调用处添加 TODO 注释
- 如果 API 文档不完整,应该:
1. 查找项目中已有的类似 API 调用作为参考
2. 查看 API 服务定义(如 `services/api.ts`
3. 查看 API 文档(如 `docs/copy-trading-requirements.md`
4. 实现一个可用的版本,包含错误处理
### 代码完成示例
```typescript
// ❌ 错误:添加 TODO 注释
const fetchAccountBalance = async (accountId: number) => {
// TODO: 调用 API 查询余额
return { balance: '0' }
}
// ✅ 正确:查找 API 定义并完成实现
// 1. 查找 API 服务定义:services/api.ts 中的 accounts.balance
// 2. 查找 API 文档:docs/copy-trading-requirements.md
// 3. 实现完整的 API 调用逻辑
const fetchAccountBalance = async (accountId: number) => {
try {
// 根据 API 服务定义调用接口
const response = await apiService.accounts.balance({ accountId })
// 根据 API 响应格式处理数据
if (response.data.code === 0 && response.data.data) {
return response.data.data
} else {
// API 调用失败时返回默认值
console.warn('查询余额失败,返回默认值:', response.data.msg)
return { balance: '0' }
}
} catch (error) {
console.error('查询余额异常:', error)
// 异常时返回默认值,确保代码可以正常运行
return { balance: '0' }
}
}
```
## 技术栈
- **框架**: React + TypeScript
- **UI库**: Ant Design 或 Material-UI(推荐 Ant Design Mobile 用于移动端)
- **HTTP客户端**: axios
- **状态管理**: Zustand 或 Redux
- **响应式设计**: 必须支持移动端和桌面端
## 移动端适配要求
### 响应式设计
- **必须支持移动端和桌面端**
- 使用响应式布局(Responsive Design
- 移动端优先(Mobile First)设计原则
- 支持触摸操作和手势
### 断点设置
- **移动端**: < 768px
- **平板**: 768px - 1024px
- **桌面端**: > 1024px
### UI 组件适配
- 使用 Ant Design 的响应式组件
- 移动端使用 Ant Design Mobile(如果使用 Ant Design
- 表格使用虚拟滚动或分页(移动端性能优化)
- 表单使用移动端友好的输入组件
### 布局适配
- 导航栏:移动端使用抽屉菜单,桌面端使用顶部导航
- 列表:移动端使用卡片布局,桌面端使用表格布局
- 按钮:移动端按钮尺寸不小于 44x44px(触摸友好)
- 间距:移动端使用更大的间距,提高可点击区域
### 性能优化
- 图片懒加载
- 代码分割(Code Splitting
- 移动端减少动画效果
- 使用 CSS 媒体查询优化样式
## 代码规范
### 组件规范
- 使用函数式组件
- 使用 TypeScript 类型定义
- 组件文件使用 PascalCase 命名
```typescript
// ✅ 正确
interface MarketProps {
marketId: string;
platform: string;
}
export const MarketCard: React.FC<MarketProps> = ({ marketId, platform }) => {
// ...
};
// ❌ 错误
export const marketCard = (props: any) => { // 禁止使用any
// ...
};
```
### API调用规范
- 使用 axios 进行 HTTP 请求
- 统一错误处理
- 使用 TypeScript 定义响应类型
```typescript
// API服务
import axios from 'axios';
interface Market {
id: string;
marketId: string;
platform: string;
title: string;
}
export const marketService = {
getMarkets: async (): Promise<Market[]> => {
const response = await axios.get<Market[]>('/api/markets');
return response.data;
},
getMarketById: async (id: string): Promise<Market> => {
const response = await axios.get<Market>(`/api/markets/${id}`);
return response.data;
}
};
```
### 状态管理规范
- 使用 Zustand 或 Redux 管理全局状态
- 本地状态使用 `useState`
- 复杂状态使用 `useReducer`
```typescript
// Zustand Store示例
import { create } from 'zustand';
interface MarketStore {
markets: Market[];
setMarkets: (markets: Market[]) => void;
}
export const useMarketStore = create<MarketStore>((set) => ({
markets: [],
setMarkets: (markets) => set({ markets }),
}));
```
## 移动端适配示例
### 响应式布局
```typescript
import { useMediaQuery } from 'react-responsive';
const MyComponent: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 });
return (
<div className={isMobile ? 'mobile-layout' : 'desktop-layout'}>
{isMobile ? <MobileView /> : <DesktopView />}
</div>
);
};
```
### 移动端导航
```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';
const ResponsiveTable: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 });
return (
<Table
dataSource={data}
columns={columns}
scroll={{ x: isMobile ? 600 : 'auto' }}
pagination={{
pageSize: isMobile ? 10 : 20,
showSizeChanger: !isMobile
}}
/>
);
};
```
## 禁止事项
### 代码质量
- ❌ 禁止使用 `any` 类型
- ❌ 禁止忽略错误处理
- ❌ 禁止硬编码API地址
- ❌ 禁止在组件中直接使用 `fetch`
- ❌ 禁止忽略移动端适配
### 类型安全
- ❌ 禁止使用 `any`
- ❌ 禁止忽略 TypeScript 类型检查
- ❌ 禁止使用 `@ts-ignore` 除非有明确原因
### 移动端适配
- ❌ 禁止固定宽度布局
- ❌ 禁止使用过小的触摸目标(< 44x44px
- ❌ 禁止忽略移动端性能优化
- ❌ 禁止使用桌面端专用的交互方式(如 hover)
+101
View File
@@ -0,0 +1,101 @@
# IDE
.idea/
*.iml
*.iws
*.ipr
.vscode/
*.swp
*.swo
*~
.DS_Store
# Gradle
backend/.gradle/
backend/build/
backend/bin/
backend/out/
backend/*.log
backend/gradle-app.setting
backend/.gradle
backend/gradle-wrapper.jar
# Kotlin
*.kt.bak
*.class
# Java
*.jar
*.war
*.ear
*.class
hs_err_pid*
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.pnpm-store/
# Frontend build
frontend/dist/
frontend/.vite/
frontend/.cache/
frontend/.temp/
frontend/.tmp/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
*.env
# Application properties (may contain sensitive data)
backend/src/main/resources/application-local.properties
backend/src/main/resources/application-dev.properties
backend/src/main/resources/application-prod.properties
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# OS
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# Database
*.db
*.sqlite
*.sqlite3
# Temporary files
*.tmp
*.temp
.cache/
# Coverage
coverage/
*.lcov
.nyc_output/
# Test
test-results/
*.test.log
# Misc
*.bak
*.backup
*.old
+239
View File
@@ -0,0 +1,239 @@
# 后端实现总结
## 已完成的工作
### 1. 项目基础结构 ✅
- ✅ 创建了完整的后端项目目录结构
- ✅ 配置了 `application.properties` 配置文件
- ✅ 创建了 Spring Boot 主应用类
- ✅ 配置了 Gradle 构建文件
### 2. 工具类 ✅
`/Users/wrbug/hype-quant/quant/src/main/kotlin/com/hypequant/util` 拷贝并适配了以下工具类:
-`SafeConvertExt.kt` - 安全类型转换扩展函数
-`MathExt.kt` - BigDecimal 数学运算扩展函数
-`SystemExt.kt` - 系统环境变量工具
-`OkHttpExt.kt` - OkHttp 客户端扩展函数
-`CategoryValidator.kt` - 分类验证工具类(新增)
### 3. Polymarket API 封装 ✅
#### 3.1 CLOB API ✅
- ✅ 定义了 `PolymarketClobApi` 接口
- ✅ 实现了 `PolymarketClobService` 服务封装
- ✅ 支持的功能:
- 获取订单簿
- 获取价格信息
- 获取中间价
- 创建订单
- 获取活跃订单
- 取消订单
- 获取交易记录
#### 3.2 Gamma API ✅
- ✅ 定义了 `PolymarketGammaApi` 接口
- ✅ 实现了 `PolymarketGammaService` 服务封装
- ✅ 支持的功能:
- 获取市场列表(带分类验证)
- 获取市场详情
- 搜索市场(带分类验证)
- 获取事件列表(带分类验证)
- 获取体育市场
- 获取加密货币市场
### 4. WebSocket 转发服务 ✅
- ✅ 实现了 `PolymarketWebSocketHandler` - WebSocket 处理器
- ✅ 实现了 `PolymarketWebSocketClient` - Polymarket RTDS 客户端
- ✅ 配置了 `WebSocketConfig` - WebSocket 配置
- ✅ 支持双向消息转发:
- 前端 → 后端 → Polymarket RTDS
- Polymarket RTDS → 后端 → 前端
### 5. 统一响应格式 ✅
- ✅ 创建了 `ApiResponse` 统一响应格式
- ✅ 提供了便捷的响应创建方法:
- `success()` - 成功响应
- `error()` - 错误响应
- `paramError()` - 参数错误
- `authError()` - 认证错误
- `notFound()` - 资源不存在
- `businessError()` - 业务逻辑错误
- `serverError()` - 服务器错误
### 6. Controller 实现 ✅
- ✅ 实现了 `MarketController` - 市场相关接口
- ✅ 提供的接口:
- `POST /api/markets/list` - 获取市场列表
- `POST /api/markets/detail` - 获取市场详情
- `POST /api/markets/search` - 搜索市场
- `POST /api/markets/sports` - 获取体育市场
- `POST /api/markets/crypto` - 获取加密货币市场
### 7. 配置类 ✅
-`RetrofitConfig` - Retrofit 和 API 客户端配置
-`WebSocketConfig` - WebSocket 配置
## 项目结构
```
backend/
├── src/main/kotlin/com/wrbug/polymarketbot/
│ ├── api/ # API 接口定义
│ │ ├── PolymarketClobApi.kt
│ │ └── PolymarketGammaApi.kt
│ ├── config/ # 配置类
│ │ ├── RetrofitConfig.kt
│ │ └── WebSocketConfig.kt
│ ├── controller/ # 控制器
│ │ └── MarketController.kt
│ ├── dto/ # 数据传输对象
│ │ └── ApiResponse.kt
│ ├── service/ # 服务层
│ │ ├── PolymarketClobService.kt
│ │ └── PolymarketGammaService.kt
│ ├── util/ # 工具类
│ │ ├── SafeConvertExt.kt
│ │ ├── MathExt.kt
│ │ ├── SystemExt.kt
│ │ ├── OkHttpExt.kt
│ │ └── CategoryValidator.kt
│ ├── websocket/ # WebSocket 处理
│ │ ├── PolymarketWebSocketHandler.kt
│ │ └── PolymarketWebSocketClient.kt
│ └── PolymarketBotApplication.kt # 主应用类
├── src/main/resources/
│ └── application.properties # 配置文件
├── build.gradle.kts # Gradle 构建配置
└── settings.gradle.kts # Gradle 设置
```
## 关键特性
### 1. 分类验证
所有涉及分类的接口都会验证分类参数,仅支持 `sports``crypto`
```kotlin
// 自动验证分类
CategoryValidator.validate(category)
```
### 2. 统一错误处理
所有接口统一返回 `ApiResponse` 格式,包含:
- `code`: 错误码
- `data`: 响应数据
- `msg`: 错误消息
### 3. WebSocket 转发
前端通过 `ws://localhost:8000/ws/polymarket` 连接,后端自动转发到 Polymarket RTDS。
### 4. 异步支持
所有 API 调用使用 Kotlin Coroutines 的 `suspend` 函数,Controller 层使用 `runBlocking` 调用。
## 依赖说明
主要依赖:
- Spring Boot 3.2.0
- Kotlin 1.9.20
- Retrofit 2.9.0
- OkHttp 4.12.0
- Java-WebSocket 1.5.4
- MySQL Connector 8.2.0
## 配置说明
### 环境变量
- `DB_USERNAME`: 数据库用户名
- `DB_PASSWORD`: 数据库密码
- `SERVER_PORT`: 服务器端口(默认 8000
- `POLYMARKET_API_KEY`: Polymarket API Key(可选)
### application.properties
```properties
# Polymarket API 配置
polymarket.clob.base-url=https://clob.polymarket.com
polymarket.gamma.base-url=https://gamma-api.polymarket.com
polymarket.rtds.ws-url=wss://ws-live-data.polymarket.com
polymarket.api-key=${POLYMARKET_API_KEY:}
```
## 使用示例
### 1. 获取市场列表
```bash
curl -X POST http://localhost:8000/api/markets/list \
-H "Content-Type: application/json" \
-d '{
"category": "sports",
"active": true,
"limit": 20
}'
```
### 2. WebSocket 连接
```javascript
const ws = new WebSocket('ws://localhost:8000/ws/polymarket');
ws.onopen = () => {
// 订阅市场数据
ws.send(JSON.stringify({
type: 'subscribe',
channel: 'market',
market: 'market_id'
}));
};
ws.onmessage = (event) => {
console.log('收到消息:', event.data);
};
```
## 下一步工作
1. **数据库实体和 Repository**
- 创建 Market、Order、Trade 等实体类
- 实现对应的 Repository 接口
- 创建 Flyway 迁移脚本
2. **数据同步服务**
- 实现市场数据同步任务
- 实现价格更新任务
- 实现订单状态同步
3. **订单管理 Controller**
- 实现订单创建接口
- 实现订单查询接口
- 实现订单取消接口
4. **测试**
- 单元测试
- 集成测试
- API 测试
5. **文档**
- API 文档
- 部署文档
## 注意事项
1. **分类限制**: 严格限制只支持 `sports``crypto` 两个分类
2. **WebSocket**: 生产环境需要配置正确的 CORS 策略
3. **API Key**: 某些接口需要配置 Polymarket API Key
4. **错误处理**: 所有异常都会被捕获并返回统一格式的错误响应
+116
View File
@@ -0,0 +1,116 @@
# Polymarket Bot Backend
Polymarket 预测市场机器人后端服务
## 功能特性
- ✅ 封装 Polymarket CLOB API(订单操作、市场数据、交易数据)
- ✅ 封装 Polymarket Gamma API(市场、事件、系列查询)
- ✅ WebSocket 转发服务(转发前端 WebSocket 连接到 Polymarket RTDS
- ✅ 统一 API 响应格式
- ✅ 分类验证(仅支持 sports 和 crypto
- ✅ 工具类支持(安全转换、数学运算、HTTP 客户端等)
## 技术栈
- Spring Boot 3.2.0
- Kotlin 1.9.20
- Retrofit 2.9.0
- OkHttp 4.12.0
- Java-WebSocket 1.5.4
- MySQL 8.2.0
- Flyway(数据库迁移)
## 项目结构
```
backend/
├── src/
│ ├── main/
│ │ ├── kotlin/com/wrbug/polymarketbot/
│ │ │ ├── api/ # API 接口定义
│ │ │ ├── config/ # 配置类
│ │ │ ├── controller/ # 控制器
│ │ │ ├── dto/ # 数据传输对象
│ │ │ ├── service/ # 服务层
│ │ │ ├── util/ # 工具类
│ │ │ └── websocket/ # WebSocket 处理
│ │ └── resources/
│ │ ├── application.properties
│ │ └── db/migration/ # Flyway 迁移脚本
│ └── test/
└── build.gradle.kts
```
## 配置说明
### 环境变量
- `DB_USERNAME`: 数据库用户名(默认: root
- `DB_PASSWORD`: 数据库密码(默认: password
- `SERVER_PORT`: 服务器端口(默认: 8000
- `CRYPTO_SECRET_KEY`: 加密密钥(用于加密存储私钥和 API Key,建议设置)
### application.properties
主要配置项:
- 数据库连接配置
- Polymarket API 地址配置
- WebSocket 地址配置
## API 接口
### 市场相关
- `POST /api/markets/list` - 获取市场列表
- `POST /api/markets/detail` - 获取市场详情
- `POST /api/markets/search` - 搜索市场
- `POST /api/markets/sports` - 获取体育市场
- `POST /api/markets/crypto` - 获取加密货币市场
### WebSocket
- `WS /ws/polymarket` - WebSocket 连接端点(转发到 Polymarket RTDS
## 响应格式
所有接口统一返回以下格式:
```json
{
"code": 0,
"data": {},
"msg": ""
}
```
- `code`: 0 表示成功,非 0 表示失败
- `data`: 响应数据
- `msg`: 响应消息
## 错误码
- `0`: 成功
- `1001-1999`: 参数错误
- `2001-2999`: 认证/权限错误
- `3001-3999`: 资源不存在
- `4001-4999`: 业务逻辑错误
- `5001-5999`: 服务器内部错误
## 运行
```bash
# 构建项目
./gradlew build
# 运行应用
./gradlew bootRun
```
## 注意事项
1. 仅支持 Polymarket 平台
2. 仅支持 `sports``crypto` 两个分类
3. WebSocket 转发需要配置正确的 Polymarket RTDS 地址
4. 生产环境需要配置正确的 CORS 策略
+75
View File
@@ -0,0 +1,75 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "3.2.0"
id("io.spring.dependency-management") version "1.1.4"
kotlin("jvm") version "1.9.20"
kotlin("plugin.spring") version "1.9.20"
kotlin("plugin.jpa") version "1.9.20"
}
group = "com.wrbug"
version = "1.0.0"
java {
sourceCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
}
dependencies {
// Spring Boot
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-websocket")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// OkHttp
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// WebSocket Client
implementation("org.java-websocket:Java-WebSocket:1.5.4")
// Database
implementation("com.mysql:mysql-connector-j:8.2.0")
implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-mysql")
// Jackson
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("com.fasterxml.jackson.core:jackson-databind")
// Keccak-256 for Ethereum function selector
implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
// Logging
implementation("org.slf4j:slf4j-api")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "17"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+2
View File
@@ -0,0 +1,2 @@
rootProject.name = "polymarket-bot-backend"
@@ -0,0 +1,12 @@
package com.wrbug.polymarketbot
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class PolymarketBotApplication
fun main(args: Array<String>) {
runApplication<PolymarketBotApplication>(*args)
}
@@ -0,0 +1,48 @@
package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Ethereum RPC API 接口定义
* 用于调用 Ethereum JSON-RPC 接口
*/
interface EthereumRpcApi {
/**
* 调用 Ethereum JSON-RPC 方法
*/
@POST("/")
suspend fun call(@Body request: JsonRpcRequest): Response<JsonRpcResponse>
}
/**
* JSON-RPC 请求
*/
data class JsonRpcRequest(
val jsonrpc: String = "2.0",
val method: String,
val params: List<Any>,
val id: Int = 1
)
/**
* JSON-RPC 响应
*/
data class JsonRpcResponse(
val jsonrpc: String? = null,
val result: String? = null,
val error: JsonRpcError? = null,
val id: Int? = null
)
/**
* JSON-RPC 错误
*/
data class JsonRpcError(
val code: Int,
val message: String,
val data: Any? = null
)
@@ -0,0 +1,207 @@
package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.*
/**
* Polymarket CLOB API 接口定义
* 用于程序化地管理市场订单
*/
interface PolymarketClobApi {
/**
* 获取订单簿
*/
@GET("/book")
suspend fun getOrderbook(
@Query("market") market: String
): Response<OrderbookResponse>
/**
* 获取价格信息
*/
@GET("/price")
suspend fun getPrice(
@Query("market") market: String
): Response<PriceResponse>
/**
* 获取中间价
*/
@GET("/midpoint")
suspend fun getMidpoint(
@Query("market") market: String
): Response<MidpointResponse>
/**
* 获取价差
*/
@GET("/spreads")
suspend fun getSpreads(
@Query("market") market: String
): Response<SpreadsResponse>
/**
* 创建单个订单
*/
@POST("/orders")
suspend fun createOrder(
@Body request: CreateOrderRequest
): Response<OrderResponse>
/**
* 批量创建订单
*/
@POST("/orders/batch")
suspend fun createOrdersBatch(
@Body request: CreateOrdersBatchRequest
): Response<List<OrderResponse>>
/**
* 获取订单信息
*/
@GET("/orders/{orderId}")
suspend fun getOrder(
@Path("orderId") orderId: String
): Response<OrderResponse>
/**
* 获取活跃订单
* 端点: /data/orders
* 注意:Polymarket CLOB API 使用 GET 方法,参数通过 query params 传递
* 虽然项目规范要求使用 POST,但这是外部 API,必须遵循 API 的实际要求
*/
@GET("/data/orders")
suspend fun getActiveOrders(
@Query("id") id: String? = null,
@Query("market") market: String? = null,
@Query("asset_id") asset_id: String? = null,
@Query("next_cursor") next_cursor: String? = null
): Response<GetActiveOrdersResponse>
/**
* 取消订单
*/
@DELETE("/orders/{orderId}")
suspend fun cancelOrder(
@Path("orderId") orderId: String
): Response<CancelOrderResponse>
/**
* 批量取消订单
*/
@DELETE("/orders/batch")
suspend fun cancelOrdersBatch(
@Body request: CancelOrdersBatchRequest
): Response<CancelOrdersBatchResponse>
/**
* 获取交易记录
* 端点: /data/trades
* 注意:Polymarket CLOB API 使用 GET 方法,参数通过 query params 传递
*/
@GET("/data/trades")
suspend fun getTrades(
@Query("id") id: String? = null,
@Query("maker_address") maker_address: String? = null,
@Query("market") market: String? = null,
@Query("asset_id") asset_id: String? = null,
@Query("before") before: String? = null,
@Query("after") after: String? = null,
@Query("next_cursor") next_cursor: String? = null
): Response<GetTradesResponse>
}
// 请求和响应数据类
data class CreateOrderRequest(
val market: String,
val side: String, // "BUY" or "SELL"
val price: String,
val size: String,
val type: String = "LIMIT",
val expiration: Long? = null
)
data class CreateOrdersBatchRequest(
val orders: List<CreateOrderRequest>
)
data class CancelOrdersBatchRequest(
val orderIds: List<String>
)
data class OrderbookResponse(
val bids: List<OrderbookEntry>,
val asks: List<OrderbookEntry>
)
data class OrderbookEntry(
val price: String,
val size: String
)
data class PriceResponse(
val market: String,
val lastPrice: String?,
val bestBid: String?,
val bestAsk: String?
)
data class MidpointResponse(
val market: String,
val midpoint: String
)
data class SpreadsResponse(
val market: String,
val spread: String
)
data class OrderResponse(
val id: String,
val market: String,
val side: String,
val price: String,
val size: String,
val filled: String,
val status: String,
val createdAt: String // ISO 8601 格式字符串
)
data class CancelOrderResponse(
val orderId: String,
val status: String
)
data class CancelOrdersBatchResponse(
val cancelled: List<String>,
val failed: List<String>
)
data class TradeResponse(
val id: String,
val market: String,
val side: String,
val price: String,
val size: String,
val timestamp: String, // ISO 8601 格式字符串
val user: String?
)
/**
* 获取活跃订单响应
* 注意:参数通过 @Query 传递,不需要单独的 Request 类
*/
data class GetActiveOrdersResponse(
val data: List<OrderResponse>,
val next_cursor: String? = null
)
/**
* 获取交易记录响应
*/
data class GetTradesResponse(
val data: List<TradeResponse>,
val next_cursor: String? = null
)
@@ -0,0 +1,65 @@
package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Polymarket Data API 接口定义
* 用于查询仓位信息
* Base URL: https://data-api.polymarket.com
*/
interface PolymarketDataApi {
/**
* 获取用户当前仓位
* 文档: https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user
*/
@GET("/positions")
suspend fun getPositions(
@Query("user") user: String,
@Query("market") market: String? = null,
@Query("eventId") eventId: String? = null,
@Query("sizeThreshold") sizeThreshold: Double? = null,
@Query("redeemable") redeemable: Boolean? = null,
@Query("mergeable") mergeable: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
@Query("sortBy") sortBy: String? = null,
@Query("sortDirection") sortDirection: String? = null,
@Query("title") title: String? = null
): Response<List<PositionResponse>>
}
/**
* 仓位响应(根据 Polymarket Data API 文档)
*/
data class PositionResponse(
val proxyWallet: String,
val asset: String? = null,
val conditionId: String? = null,
val size: Double? = null,
val avgPrice: Double? = null,
val initialValue: Double? = null,
val currentValue: Double? = null,
val cashPnl: Double? = null,
val percentPnl: Double? = null,
val totalBought: Double? = null,
val realizedPnl: Double? = null,
val percentRealizedPnl: Double? = null,
val curPrice: Double? = null,
val redeemable: Boolean? = null,
val mergeable: Boolean? = null,
val title: String? = null,
val slug: String? = null,
val icon: String? = null,
val eventSlug: String? = null,
val outcome: String? = null,
val outcomeIndex: Int? = null,
val oppositeOutcome: String? = null,
val oppositeAsset: String? = null,
val endDate: String? = null,
val negativeRisk: Boolean? = null
)
@@ -0,0 +1,45 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.api.PolymarketClobApi
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
import retrofit2.converter.gson.GsonConverterFactory
/**
* Retrofit 配置类
* 用于创建 Polymarket CLOB API 客户端(跟单系统需要)
*
* 注意:
* - 查询类接口(如 /book, /price, /trades)不需要认证
* - 操作类接口(如 /orders)需要认证,应使用账户级别的 API Key
* - 账户 API Key 在调用时动态设置,不在此处配置
*/
@Configuration
class RetrofitConfig {
@Value("\${polymarket.clob.base-url}")
private lateinit var clobBaseUrl: String
/**
* 创建 CLOB API 客户端
* 用于跟单系统的订单操作和交易查询
*
* 注意:此客户端不包含全局认证拦截器
* 需要认证的请求应在调用时使用账户级别的 API Key 动态设置认证头
*/
@Bean
fun polymarketClobApi(): PolymarketClobApi {
val okHttpClient = createClient().build()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(PolymarketClobApi::class.java)
}
}
@@ -0,0 +1,24 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler
import org.springframework.context.annotation.Configuration
import org.springframework.web.socket.config.annotation.EnableWebSocket
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
/**
* WebSocket 配置类
* 用于配置 WebSocket 端点
*/
@Configuration
@EnableWebSocket
class WebSocketConfig(
private val polymarketWebSocketHandler: PolymarketWebSocketHandler
) : WebSocketConfigurer {
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
}
}
@@ -0,0 +1,208 @@
package com.wrbug.polymarketbot.controller
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.service.AccountService
import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* 账户管理控制器
*/
@RestController
@RequestMapping("/api/copy-trading/accounts")
class AccountController(
private val accountService: AccountService
) {
private val logger = LoggerFactory.getLogger(AccountController::class.java)
/**
* 通过私钥导入账户
*/
@PostMapping("/import")
fun importAccount(@RequestBody request: AccountImportRequest): ResponseEntity<ApiResponse<AccountDto>> {
return try {
// 参数验证
if (request.privateKey.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("私钥不能为空"))
}
if (request.walletAddress.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("钱包地址不能为空"))
}
val result = accountService.importAccount(request)
result.fold(
onSuccess = { account ->
logger.info("成功导入账户: ${account.id}")
ResponseEntity.ok(ApiResponse.success(account))
},
onFailure = { e ->
logger.error("导入账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("导入账户失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("导入账户异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("导入账户失败: ${e.message}"))
}
}
/**
* 更新账户信息
*/
@PostMapping("/update")
fun updateAccount(@RequestBody request: AccountUpdateRequest): ResponseEntity<ApiResponse<AccountDto>> {
return try {
val result = accountService.updateAccount(request)
result.fold(
onSuccess = { account ->
logger.info("成功更新账户: ${account.id}")
ResponseEntity.ok(ApiResponse.success(account))
},
onFailure = { e ->
logger.error("更新账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("更新账户失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("更新账户异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("更新账户失败: ${e.message}"))
}
}
/**
* 删除账户
*/
@PostMapping("/delete")
fun deleteAccount(@RequestBody request: AccountDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
val result = accountService.deleteAccount(request.accountId)
result.fold(
onSuccess = {
logger.info("成功删除账户: ${request.accountId}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("删除账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
is IllegalStateException -> ResponseEntity.ok(ApiResponse.businessError(e.message ?: "业务逻辑错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("删除账户失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("删除账户异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("删除账户失败: ${e.message}"))
}
}
/**
* 查询账户列表
*/
@PostMapping("/list")
fun getAccountList(): ResponseEntity<ApiResponse<AccountListResponse>> {
return try {
val result = accountService.getAccountList()
result.fold(
onSuccess = { response ->
logger.info("成功查询账户列表: ${response.total} 个账户")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询账户列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("查询账户列表失败: ${e.message}"))
}
)
} catch (e: Exception) {
logger.error("查询账户列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("查询账户列表失败: ${e.message}"))
}
}
/**
* 查询账户详情
*/
@PostMapping("/detail")
fun getAccountDetail(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountDto>> {
return try {
val result = accountService.getAccountDetail(request.accountId)
result.fold(
onSuccess = { account ->
logger.info("成功查询账户详情: ${account.id}")
ResponseEntity.ok(ApiResponse.success(account))
},
onFailure = { e ->
logger.error("查询账户详情失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("查询账户详情失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("查询账户详情异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("查询账户详情失败: ${e.message}"))
}
}
/**
* 查询账户余额
*/
@PostMapping("/balance")
fun getAccountBalance(@RequestBody request: AccountBalanceRequest): ResponseEntity<ApiResponse<AccountBalanceResponse>> {
return try {
val result = accountService.getAccountBalance(request.accountId)
result.fold(
onSuccess = { balance ->
logger.info("成功查询账户余额")
ResponseEntity.ok(ApiResponse.success(balance))
},
onFailure = { e ->
logger.error("查询账户余额失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("查询账户余额失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("查询账户余额异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("查询账户余额失败: ${e.message}"))
}
}
/**
* 设置默认账户
*/
@PostMapping("/set-default")
fun setDefaultAccount(@RequestBody request: SetDefaultAccountRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
val result = accountService.setDefaultAccount(request.accountId)
result.fold(
onSuccess = {
logger.info("成功设置默认账户: ${request.accountId}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("设置默认账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("设置默认账户失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("设置默认账户异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("设置默认账户失败: ${e.message}"))
}
}
}
@@ -0,0 +1,101 @@
package com.wrbug.polymarketbot.dto
/**
* 账户导入请求
*/
data class AccountImportRequest(
val privateKey: String, // 私钥(前端加密后传输)
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
val accountName: String? = null,
val apiKey: String? = null, // Polymarket API Key(可选)
val apiSecret: String? = null, // Polymarket API Secret(可选)
val apiPassphrase: String? = null, // Polymarket API Passphrase(可选)
val isDefault: Boolean = false
)
/**
* 账户更新请求
*/
data class AccountUpdateRequest(
val accountId: Long,
val accountName: String? = null,
val apiKey: String? = null,
val apiSecret: String? = null,
val apiPassphrase: String? = null,
val isDefault: Boolean? = null
)
/**
* 账户删除请求
*/
data class AccountDeleteRequest(
val accountId: Long
)
/**
* 账户详情请求
*/
data class AccountDetailRequest(
val accountId: Long? = null // 不提供则返回默认账户
)
/**
* 账户余额请求
*/
data class AccountBalanceRequest(
val accountId: Long? = null // 不提供则查询默认账户
)
/**
* 设置默认账户请求
*/
data class SetDefaultAccountRequest(
val accountId: Long
)
/**
* 账户信息响应
*/
data class AccountDto(
val id: Long,
val walletAddress: String,
val accountName: String?,
val isDefault: Boolean,
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
val apiSecretConfigured: Boolean, // API Secret 是否已配置
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
val balance: String? = null, // 账户余额(可选)
val totalOrders: Long? = null, // 总订单数(可选)
val totalPnl: String? = null // 总盈亏(可选)
)
/**
* 账户列表响应
*/
data class AccountListResponse(
val list: List<AccountDto>,
val total: Long
)
/**
* 账户余额响应
*/
data class AccountBalanceResponse(
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
/**
* 持仓信息
*/
data class PositionDto(
val marketId: String,
val side: String, // YES 或 NO
val quantity: String,
val avgPrice: String,
val currentValue: String,
val pnl: String? = null
)
@@ -0,0 +1,65 @@
package com.wrbug.polymarketbot.dto
/**
* 统一API响应格式
* @param code 响应码,0表示成功,非0表示失败
* @param data 响应数据,可以是任意类型(对象、数组、字符串、数字等)
* @param msg 响应消息,成功时通常为空,失败时包含错误提示
*/
data class ApiResponse<T>(
val code: Int,
val data: T?,
val msg: String
) {
companion object {
/**
* 创建成功响应
*/
fun <T> success(data: T?): ApiResponse<T> {
return ApiResponse(code = 0, data = data, msg = "")
}
/**
* 创建失败响应
*/
fun <T> error(code: Int, msg: String): ApiResponse<T> {
return ApiResponse(code = code, data = null, msg = msg)
}
/**
* 创建参数错误响应
*/
fun <T> paramError(msg: String): ApiResponse<T> {
return ApiResponse(code = 1001, data = null, msg = msg)
}
/**
* 创建认证错误响应
*/
fun <T> authError(msg: String): ApiResponse<T> {
return ApiResponse(code = 2001, data = null, msg = msg)
}
/**
* 创建资源不存在响应
*/
fun <T> notFound(msg: String): ApiResponse<T> {
return ApiResponse(code = 3001, data = null, msg = msg)
}
/**
* 创建业务逻辑错误响应
*/
fun <T> businessError(msg: String): ApiResponse<T> {
return ApiResponse(code = 4001, data = null, msg = msg)
}
/**
* 创建服务器内部错误响应
*/
fun <T> serverError(msg: String): ApiResponse<T> {
return ApiResponse(code = 5001, data = null, msg = msg)
}
}
}
@@ -0,0 +1,73 @@
package com.wrbug.polymarketbot.dto
/**
* 市场 DTO(用于返回给前端)
*/
data class MarketDto(
val id: String,
val question: String,
val slug: String,
val category: String,
val active: Boolean,
val volume: String?,
val liquidity: String?,
val endDate: Long?, // 时间戳(毫秒)
val createdAt: Long?, // 时间戳(毫秒)
val updatedAt: Long?, // 时间戳(毫秒)
val outcomes: List<OutcomeDto>?,
val conditionId: String?,
val description: String?,
val image: String?,
val icon: String?,
val closed: Boolean?,
val archived: Boolean?,
val volumeNum: Double?,
val liquidityNum: Double?,
val bestBid: Double?,
val bestAsk: Double?,
val lastTradePrice: Double?
)
/**
* 结果 DTO
*/
data class OutcomeDto(
val name: String, // 结果名称,如 "Yes" 或 "No"
val price: String // 价格
)
/**
* 事件 DTO
*/
data class EventDto(
val id: String,
val title: String,
val category: String,
val active: Boolean,
val markets: List<MarketDto>?,
val createdAt: Long? // 时间戳(毫秒)
)
/**
* 系列 DTO
*/
data class SeriesDto(
val id: String,
val title: String,
val category: String,
val events: List<EventDto>?,
val createdAt: Long? // 时间戳(毫秒)
)
/**
* 评论 DTO
*/
data class CommentDto(
val id: String,
val market: String,
val content: String,
val parent: String?,
val createdAt: Long, // 时间戳(毫秒)
val user: String?
)
@@ -0,0 +1,29 @@
package com.wrbug.polymarketbot.dto
/**
* 订单 DTO(用于返回给前端)
*/
data class OrderDto(
val id: String,
val market: String,
val side: String,
val price: String,
val size: String,
val filled: String,
val status: String,
val createdAt: Long // 时间戳(毫秒)
)
/**
* 交易 DTO
*/
data class TradeDto(
val id: String,
val market: String,
val side: String,
val price: String,
val size: String,
val timestamp: Long, // 时间戳(毫秒)
val user: String?
)
@@ -0,0 +1,46 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* 账户信息实体
* 用于存储跟单者的账户信息(支持多账户)
*/
@Entity
@Table(name = "copy_trading_accounts")
data class Account(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "private_key", nullable = false, length = 500)
val privateKey: String, // 私钥(加密存储)
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导)
@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(可选,加密存储)
@Column(name = "api_secret", length = 500)
val apiSecret: String? = null, // Polymarket API Secret(可选,加密存储)
@Column(name = "api_passphrase", length = 500)
val apiPassphrase: String? = null, // Polymarket API Passphrase(可选,加密存储)
@Column(name = "account_name", length = 100)
val accountName: String? = null,
@Column(name = "is_default", nullable = false)
val isDefault: Boolean = false, // 是否默认账户
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,33 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.Account
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
/**
* 账户 Repository
*/
@Repository
interface AccountRepository : JpaRepository<Account, Long> {
/**
* 根据钱包地址查找账户
*/
fun findByWalletAddress(walletAddress: String): Account?
/**
* 查找默认账户
*/
fun findByIsDefaultTrue(): Account?
/**
* 查找所有账户,按创建时间排序
*/
fun findAllByOrderByCreatedAtAsc(): List<Account>
/**
* 检查钱包地址是否存在
*/
fun existsByWalletAddress(walletAddress: String): Boolean
}
@@ -0,0 +1,536 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
/**
* 账户管理服务
*/
@Service
class AccountService(
private val accountRepository: AccountRepository,
private val cryptoUtils: CryptoUtils,
private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory,
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(AccountService::class.java)
/**
* 通过私钥导入账户
*/
@Transactional
fun importAccount(request: AccountImportRequest): Result<AccountDto> {
return try {
// 1. 验证钱包地址格式
if (!isValidWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 检查地址是否已存在
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
}
// 3. 验证私钥和地址的对应关系
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
if (!isValidPrivateKey(request.privateKey)) {
return Result.failure(IllegalArgumentException("无效的私钥格式"))
}
// 4. 加密私钥和 API 凭证
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
val encryptedApiKey = request.apiKey?.let { cryptoUtils.encrypt(it) }
val encryptedApiSecret = request.apiSecret?.let { cryptoUtils.encrypt(it) }
val encryptedApiPassphrase = request.apiPassphrase?.let { cryptoUtils.encrypt(it) }
// 5. 如果设置为默认账户,取消其他账户的默认状态
if (request.isDefault) {
accountRepository.findByIsDefaultTrue()?.let { defaultAccount ->
val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis())
accountRepository.save(updated)
}
}
// 6. 获取代理地址(必须成功,否则导入失败)
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(request.walletAddress)
if (proxyResult.isSuccess) {
val address = proxyResult.getOrNull()
if (address != null) {
logger.info("成功获取代理地址: ${request.walletAddress} -> $address")
address
} else {
logger.error("获取代理地址返回空值")
throw IllegalStateException("获取代理地址失败:返回值为空")
}
} else {
val error = proxyResult.exceptionOrNull()
logger.error("获取代理地址失败: ${error?.message}")
throw IllegalStateException("获取代理地址失败: ${error?.message}。请确保已配置 Ethereum RPC URL 且 RPC 节点可用")
}
}
// 7. 创建账户
val account = Account(
privateKey = encryptedPrivateKey,
walletAddress = request.walletAddress,
proxyAddress = proxyAddress,
apiKey = encryptedApiKey,
apiSecret = encryptedApiSecret,
apiPassphrase = encryptedApiPassphrase,
accountName = request.accountName,
isDefault = request.isDefault,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
val saved = accountRepository.save(account)
logger.info("成功导入账户: ${saved.id}, ${saved.walletAddress}, 代理地址: ${saved.proxyAddress}")
Result.success(toDto(saved))
} catch (e: Exception) {
logger.error("导入账户失败", e)
Result.failure(e)
}
}
/**
* 更新账户信息
*/
@Transactional
fun updateAccount(request: AccountUpdateRequest): Result<AccountDto> {
return try {
val account = accountRepository.findById(request.accountId)
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 更新账户名称
val updatedAccountName = request.accountName ?: account.accountName
// 更新 API 凭证
val updatedApiKey = if (request.apiKey != null) {
cryptoUtils.encrypt(request.apiKey)
} else {
account.apiKey
}
val updatedApiSecret = if (request.apiSecret != null) {
cryptoUtils.encrypt(request.apiSecret)
} else {
account.apiSecret
}
val updatedApiPassphrase = if (request.apiPassphrase != null) {
cryptoUtils.encrypt(request.apiPassphrase)
} else {
account.apiPassphrase
}
// 如果设置为默认账户,取消其他账户的默认状态
val updatedIsDefault = request.isDefault ?: account.isDefault
if (updatedIsDefault && !account.isDefault) {
accountRepository.findByIsDefaultTrue()?.let { defaultAccount ->
val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis())
accountRepository.save(updated)
}
}
val updated = account.copy(
accountName = updatedAccountName,
apiKey = updatedApiKey,
apiSecret = updatedApiSecret,
apiPassphrase = updatedApiPassphrase,
isDefault = updatedIsDefault,
updatedAt = System.currentTimeMillis()
)
val saved = accountRepository.save(updated)
logger.info("成功更新账户: ${saved.id}")
Result.success(toDto(saved))
} catch (e: Exception) {
logger.error("更新账户失败", e)
Result.failure(e)
}
}
/**
* 删除账户
*/
@Transactional
fun deleteAccount(accountId: Long): Result<Unit> {
return try {
val account = accountRepository.findById(accountId)
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 注意:不再检查活跃订单,允许用户删除有活跃订单的账户
// 前端会显示确认提示框,由用户决定是否删除
// 如果删除的是默认账户,需要先设置其他账户为默认
if (account.isDefault) {
val otherAccounts = accountRepository.findAllByOrderByCreatedAtAsc()
.filter { it.id != accountId }
if (otherAccounts.isNotEmpty()) {
val newDefault = otherAccounts.first().copy(
isDefault = true,
updatedAt = System.currentTimeMillis()
)
accountRepository.save(newDefault)
} else {
return Result.failure(IllegalStateException("不能删除最后一个账户"))
}
}
accountRepository.delete(account)
logger.info("成功删除账户: $accountId")
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除账户失败", e)
Result.failure(e)
}
}
/**
* 查询账户列表
*/
fun getAccountList(): Result<AccountListResponse> {
return try {
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
val accountDtos = accounts.map { toDto(it) }
Result.success(AccountListResponse(
list = accountDtos,
total = accountDtos.size.toLong()
))
} catch (e: Exception) {
logger.error("查询账户列表失败", e)
Result.failure(e)
}
}
/**
* 查询账户详情
*/
fun getAccountDetail(accountId: Long?): Result<AccountDto> {
return try {
val account = if (accountId != null) {
accountRepository.findById(accountId).orElse(null)
} else {
accountRepository.findByIsDefaultTrue()
}
account ?: return Result.failure(IllegalArgumentException("账户不存在"))
Result.success(toDto(account))
} catch (e: Exception) {
logger.error("查询账户详情失败", e)
Result.failure(e)
}
}
/**
* 查询账户余额
* 通过链上 RPC 查询 USDC 余额,并通过 Subgraph API 查询持仓信息
*/
fun getAccountBalance(accountId: Long?): Result<AccountBalanceResponse> {
return try {
val account = if (accountId != null) {
accountRepository.findById(accountId).orElse(null)
} else {
accountRepository.findByIsDefaultTrue()
}
account ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 检查代理地址是否存在
if (account.proxyAddress.isBlank()) {
logger.error("账户 ${account.id} 的代理地址为空,无法查询余额")
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
}
// 查询 USDC 余额和持仓信息
val balanceResult = runBlocking {
try {
// 先查询持仓信息(用于计算仓位余额和返回持仓列表)
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
val positionsResult = blockchainService.getPositions(account.proxyAddress)
val positions = if (positionsResult.isSuccess) {
positionsResult.getOrNull()?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 计算仓位余额(持仓总价值)
val positionBalance = positions.sumOf {
it.currentValue.toSafeBigDecimal()
}
// 查询可用余额(通过 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)
AccountBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance.toPlainString(),
totalBalance = totalBalance.toPlainString(),
positions = positions
)
} catch (e: Exception) {
logger.error("查询余额失败: ${e.message}", e)
throw e
}
}
Result.success(balanceResult)
} catch (e: Exception) {
logger.error("查询账户余额失败", e)
Result.failure(e)
}
}
/**
* 设置默认账户
*/
@Transactional
fun setDefaultAccount(accountId: Long): Result<Unit> {
return try {
val account = accountRepository.findById(accountId)
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 取消其他账户的默认状态
accountRepository.findByIsDefaultTrue()?.let { defaultAccount ->
if (defaultAccount.id != account.id) {
val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis())
accountRepository.save(updated)
}
}
// 设置当前账户为默认
val updated = account.copy(isDefault = true, updatedAt = System.currentTimeMillis())
accountRepository.save(updated)
logger.info("成功设置默认账户: $accountId")
Result.success(Unit)
} catch (e: Exception) {
logger.error("设置默认账户失败", e)
Result.failure(e)
}
}
/**
* 转换为 DTO
* 包含交易统计数据(总订单数和总盈亏)
*/
private fun toDto(account: Account): AccountDto {
return runBlocking {
val statistics = getAccountStatistics(account)
AccountDto(
id = account.id!!,
walletAddress = account.walletAddress,
accountName = account.accountName,
isDefault = account.isDefault,
apiKeyConfigured = account.apiKey != null,
apiSecretConfigured = account.apiSecret != null,
apiPassphraseConfigured = account.apiPassphrase != null,
totalOrders = statistics.totalOrders,
totalPnl = statistics.totalPnl
)
}
}
/**
* 获取账户交易统计数据
*/
private suspend fun getAccountStatistics(account: Account): AccountStatistics {
return try {
// 如果账户没有配置 API 凭证,无法查询统计数据
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
return AccountStatistics(totalOrders = null, totalPnl = null)
}
// 解密 API 凭证
val apiKey = cryptoUtils.decrypt(account.apiKey)
val apiSecret = cryptoUtils.decrypt(account.apiSecret)
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase)
// 创建带认证的 API 客户端
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
// 1. 查询交易记录数量(总订单数)
val tradesResult = runBlocking {
try {
// 使用代理地址查询交易记录
val response = clobApi.getTrades(
maker_address = account.proxyAddress,
next_cursor = null
)
if (response.isSuccessful && response.body() != null) {
val tradesResponse = response.body()!!
// 统计所有交易(需要分页查询所有)
var totalTrades = tradesResponse.data.size
var nextCursor = tradesResponse.next_cursor
// 分页查询所有交易
while (nextCursor != null && nextCursor.isNotEmpty()) {
val nextResponse = clobApi.getTrades(
maker_address = account.proxyAddress,
next_cursor = nextCursor
)
if (nextResponse.isSuccessful && nextResponse.body() != null) {
val nextTradesResponse = nextResponse.body()!!
totalTrades += nextTradesResponse.data.size
nextCursor = nextTradesResponse.next_cursor
} else {
break
}
}
Result.success(totalTrades.toLong())
} else {
Result.failure(Exception("查询交易记录失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.warn("查询交易记录失败: ${e.message}", e)
Result.failure(e)
}
}
// 2. 查询仓位信息计算总盈亏(已实现盈亏)
val totalPnlResult = runBlocking {
try {
val positionsResult = blockchainService.getPositions(account.proxyAddress)
if (positionsResult.isSuccess) {
val positions = positionsResult.getOrNull() ?: emptyList()
// 汇总所有仓位的已实现盈亏
val totalRealizedPnl = positions.sumOf { pos ->
pos.realizedPnl?.toSafeBigDecimal() ?: BigDecimal.ZERO
}
Result.success(totalRealizedPnl.toPlainString())
} else {
Result.failure(Exception("查询仓位信息失败"))
}
} catch (e: Exception) {
logger.warn("查询仓位盈亏失败: ${e.message}", e)
Result.failure(e)
}
}
AccountStatistics(
totalOrders = tradesResult.getOrNull(),
totalPnl = totalPnlResult.getOrNull()
)
} catch (e: Exception) {
logger.warn("获取账户统计数据失败: ${e.message}", e)
AccountStatistics(totalOrders = null, totalPnl = null)
}
}
/**
* 账户统计数据
*/
private data class AccountStatistics(
val totalOrders: Long?,
val totalPnl: String?
)
/**
* 验证钱包地址格式
*/
private fun isValidWalletAddress(address: String): Boolean {
// 以太坊地址格式:0x 开头,42 位字符
return address.startsWith("0x") && address.length == 42 && address.matches(Regex("^0x[0-9a-fA-F]{40}$"))
}
/**
* 验证私钥格式
*/
private fun isValidPrivateKey(privateKey: String): Boolean {
// 私钥格式:64 位十六进制字符(可选 0x 前缀)
val cleanKey = if (privateKey.startsWith("0x")) privateKey.substring(2) else privateKey
return cleanKey.length == 64 && cleanKey.matches(Regex("^[0-9a-fA-F]{64}$"))
}
/**
* 检查账户是否有活跃订单
* 使用账户的 API Key 查询该账户的活跃订单
*/
private suspend fun hasActiveOrders(account: Account): Boolean {
return try {
// 如果账户没有配置 API 凭证,无法查询活跃订单,允许删除
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
logger.debug("账户 ${account.id} 未配置 API 凭证,无法查询活跃订单,允许删除")
return false
}
// 解密 API 凭证(前面已检查不为 null)
val apiKey = cryptoUtils.decrypt(account.apiKey)
val apiSecret = cryptoUtils.decrypt(account.apiSecret)
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase)
// 创建带认证的 API 客户端
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
// 查询活跃订单(只查询第一条,用于判断是否有订单)
// 使用 next_cursor 参数进行分页,这里只查询第一页
val response = clobApi.getActiveOrders(
id = null,
market = null,
asset_id = null,
next_cursor = null // null 表示从第一页开始
)
if (response.isSuccessful && response.body() != null) {
val ordersResponse = response.body()!!
val hasOrders = ordersResponse.data.isNotEmpty()
logger.debug("账户 ${account.id} 活跃订单检查结果: $hasOrders (订单数: ${ordersResponse.data.size})")
hasOrders
} else {
// 如果查询失败(可能是认证失败或网络问题),记录警告但允许删除
// 因为无法确定是否有活跃订单,不应该阻止删除操作
logger.warn("查询活跃订单失败: ${response.code()} ${response.message()},允许删除账户")
false
}
} catch (e: Exception) {
// 如果查询异常(网络问题、API 错误等),记录警告但允许删除
// 因为无法确定是否有活跃订单,不应该阻止删除操作
logger.warn("检查活跃订单异常: ${e.message},允许删除账户", e)
false
}
}
}
@@ -0,0 +1,242 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.JsonRpcRequest
import com.wrbug.polymarketbot.api.JsonRpcResponse
import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.PositionResponse
import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.math.BigDecimal
import java.math.BigInteger
/**
* 区块链查询服务
* 用于查询链上余额和持仓信息
*/
@Service
class BlockchainService(
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
private val dataApiBaseUrl: String,
@Value("\${ethereum.rpc.url:}")
private val ethereumRpcUrl: String,
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// Polymarket 代理工厂合约地址(Polygon 主网)
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
private val proxyFactoryContractAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
// 获取代理地址的函数签名
// 根据 Polygonscan 的 F4 方法,函数签名为: computeProxyAddress(address)
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
private val dataApi: PolymarketDataApi by lazy {
val baseUrl = if (dataApiBaseUrl.endsWith("/")) {
dataApiBaseUrl.dropLast(1)
} else {
dataApiBaseUrl
}
val okHttpClient = createClient()
.followRedirects(true)
.followSslRedirects(true)
.build()
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(PolymarketDataApi::class.java)
}
private val ethereumRpcApi: EthereumRpcApi? by lazy {
if (ethereumRpcUrl.isBlank()) {
null
} else {
retrofitFactory.createEthereumRpcApi(ethereumRpcUrl)
}
}
/**
* 获取 Polymarket 代理钱包地址
* 通过 RPC 调用代理工厂合约获取用户的代理钱包地址
* @param walletAddress 用户的钱包地址
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String): Result<String> {
return try {
// 如果未配置 RPC URL,返回错误
if (ethereumRpcUrl.isBlank()) {
logger.warn("未配置 Ethereum RPC URL,无法获取代理地址")
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法获取代理地址。请在配置文件中设置 ethereum.rpc.url 环境变量"))
}
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
// 计算函数选择器
val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature)
// 编码地址参数
val encodedAddress = EthereumUtils.encodeAddress(walletAddress)
// 构建调用数据
val data = functionSelector + encodedAddress
// 构建 JSON-RPC 请求
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to proxyFactoryContractAddress,
"data" to data
),
"latest"
)
)
// 发送 RPC 请求
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
throw Exception("RPC 请求失败: ${response.code()} ${response.message()}")
}
val rpcResponse = response.body()!!
// 检查错误
if (rpcResponse.error != null) {
throw Exception("RPC 错误: ${rpcResponse.error.message}")
}
val hexResult = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
// 解析代理地址
val proxyAddress = EthereumUtils.decodeAddress(hexResult)
logger.debug("获取代理地址成功: 原始地址=$walletAddress, 代理地址=$proxyAddress")
Result.success(proxyAddress)
} catch (e: Exception) {
logger.error("获取代理地址失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 查询账户 USDC 余额
* 通过 Ethereum RPC 查询 ERC-20 代币余额
* @param walletAddress 钱包地址(用于日志记录)
* @param proxyAddress 代理地址(必须提供)
* 如果 RPC 未配置或代理地址为空,返回失败(不返回 mock 数据)
*/
suspend fun getUsdcBalance(walletAddress: String, proxyAddress: String): Result<String> {
return try {
// 如果未配置 RPC URL,返回错误
if (ethereumRpcUrl.isBlank()) {
logger.warn("未配置 Ethereum RPC URL,无法查询 USDC 余额")
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法查询 USDC 余额。请在配置文件中设置 ethereum.rpc.url 环境变量"))
}
// 检查代理地址是否为空
if (proxyAddress.isBlank()) {
logger.error("代理地址为空,无法查询余额")
return Result.failure(IllegalArgumentException("代理地址不能为空"))
}
logger.debug("使用代理地址查询余额: $proxyAddress (原始地址: $walletAddress)")
// 使用 RPC 查询 USDC 余额(使用代理地址)
val balance = queryUsdcBalanceViaRpc(proxyAddress)
Result.success(balance)
} catch (e: Exception) {
logger.error("查询 USDC 余额失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 RPC 查询 USDC 余额
*/
private suspend fun queryUsdcBalanceViaRpc(walletAddress: String): String {
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
// 构建 ERC-20 balanceOf 函数调用
// function signature: balanceOf(address) -> bytes4(0x70a08231)
// 参数编码: address (32 bytes, padded)
val functionSelector = "0x70a08231" // balanceOf(address)
val paddedAddress = walletAddress.removePrefix("0x").lowercase().padStart(64, '0')
val data = functionSelector + paddedAddress
// 构建 JSON-RPC 请求
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to usdcContractAddress,
"data" to data
),
"latest"
)
)
// 发送 RPC 请求(使用 Retrofit
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
throw Exception("RPC 请求失败: ${response.code()} ${response.message()}")
}
val rpcResponse = response.body()!!
// 检查错误
if (rpcResponse.error != null) {
throw Exception("RPC 错误: ${rpcResponse.error.message}")
}
val hexBalance = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
// 将十六进制转换为 BigDecimalUSDC 有 6 位小数)
val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16)
val balance = BigDecimal(balanceWei).divide(BigDecimal("1000000")) // USDC 有 6 位小数
return balance.toPlainString()
}
/**
* 查询账户持仓信息
* 通过 Polymarket Data API 查询
* 文档: https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user
*/
suspend fun getPositions(proxyWalletAddress: String): Result<List<PositionResponse>> {
return try {
// 使用代理钱包地址查询仓位
val response = dataApi.getPositions(
user = proxyWalletAddress,
limit = 500, // 最大限制
offset = 0
)
if (response.isSuccessful && response.body() != null) {
val positions = response.body()!!
logger.debug("查询到 ${positions.size} 个仓位")
Result.success(positions)
} else {
val errorMsg = "Data API 请求失败: ${response.code()} ${response.message()}"
logger.error(errorMsg)
Result.failure(Exception(errorMsg))
}
} catch (e: Exception) {
logger.error("查询持仓信息失败: ${e.message}", e)
Result.failure(e)
}
}
}
@@ -0,0 +1,165 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.api.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
/**
* Polymarket CLOB API 服务封装
* 提供订单操作、市场数据、交易数据等功能
*/
@Service
class PolymarketClobService(
private val clobApi: PolymarketClobApi
) {
private val logger = LoggerFactory.getLogger(PolymarketClobService::class.java)
/**
* 获取订单簿
*/
suspend fun getOrderbook(market: String): Result<OrderbookResponse> {
return try {
val response = clobApi.getOrderbook(market)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("获取订单簿失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取订单簿异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取价格信息
*/
suspend fun getPrice(market: String): Result<PriceResponse> {
return try {
val response = clobApi.getPrice(market)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("获取价格失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取价格异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取中间价
*/
suspend fun getMidpoint(market: String): Result<MidpointResponse> {
return try {
val response = clobApi.getMidpoint(market)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("获取中间价失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取中间价异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 创建订单
*/
suspend fun createOrder(request: CreateOrderRequest): Result<OrderResponse> {
return try {
val response = clobApi.createOrder(request)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("创建订单失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("创建订单异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取活跃订单
*/
suspend fun getActiveOrders(
id: String? = null,
market: String? = null,
asset_id: String? = null,
next_cursor: String? = null
): Result<List<OrderResponse>> {
return try {
val response = clobApi.getActiveOrders(
id = id,
market = market,
asset_id = asset_id,
next_cursor = next_cursor
)
if (response.isSuccessful && response.body() != null) {
val ordersResponse = response.body()!!
Result.success(ordersResponse.data)
} else {
Result.failure(Exception("获取活跃订单失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取活跃订单异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 取消订单
*/
suspend fun cancelOrder(orderId: String): Result<CancelOrderResponse> {
return try {
val response = clobApi.cancelOrder(orderId)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("取消订单失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("取消订单异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取交易记录
*/
suspend fun getTrades(
id: String? = null,
maker_address: String? = null,
market: String? = null,
asset_id: String? = null,
before: String? = null,
after: String? = null,
next_cursor: String? = null
): Result<List<TradeResponse>> {
return try {
val response = clobApi.getTrades(
id = id,
maker_address = maker_address,
market = market,
asset_id = asset_id,
before = before,
after = after,
next_cursor = next_cursor
)
if (response.isSuccessful && response.body() != null) {
val tradesResponse = response.body()!!
Result.success(tradesResponse.data)
} else {
Result.failure(Exception("获取交易记录失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取交易记录异常: ${e.message}", e)
Result.failure(e)
}
}
}
@@ -0,0 +1,104 @@
package com.wrbug.polymarketbot.util
/**
* 分类验证工具类
* 用于验证分类参数是否符合项目要求(仅支持 sports 和 crypto
*/
object CategoryValidator {
/**
* 支持的分类列表
*/
private val SUPPORTED_CATEGORIES = setOf("sports", "crypto")
/**
* 分类名称映射(将 Polymarket API 返回的分类名称映射到标准分类)
*/
private val CATEGORY_MAPPING = mapOf(
"sports" to "sports",
"crypto" to "crypto",
"cryptocurrency" to "crypto",
"cryptocurrencies" to "crypto"
)
/**
* 验证分类是否有效(支持精确匹配和关键字匹配)
* @param category 分类名称
* @return 是否有效
*/
fun isValid(category: String?): Boolean {
if (category == null) {
return false
}
val categoryLower = category.lowercase()
// 精确匹配
if (categoryLower in SUPPORTED_CATEGORIES) {
return true
}
// 映射匹配
if (categoryLower in CATEGORY_MAPPING.keys) {
return true
}
// 关键字匹配
if (categoryLower.contains("sport")) {
return true
}
if (categoryLower.contains("crypto")) {
return true
}
return false
}
/**
* 标准化分类名称
* @param category 原始分类名称
* @return 标准化后的分类名称(sports 或 crypto
*/
fun normalizeCategory(category: String?): String? {
if (category == null) {
return null
}
val categoryLower = category.lowercase()
// 映射匹配
CATEGORY_MAPPING[categoryLower]?.let {
return it
}
// 关键字匹配
if (categoryLower.contains("sport")) {
return "sports"
}
if (categoryLower.contains("crypto")) {
return "crypto"
}
return null
}
/**
* 验证分类,如果无效则抛出异常
* @param category 分类名称
* @throws IllegalArgumentException 如果分类无效
*/
fun validate(category: String?) {
if (!isValid(category)) {
throw IllegalArgumentException("不支持的分类: $category,仅支持: ${SUPPORTED_CATEGORIES.joinToString(", ")}")
}
}
/**
* 获取所有支持的分类
* @return 支持的分类列表
*/
fun getSupportedCategories(): Set<String> {
return SUPPORTED_CATEGORIES
}
}
@@ -0,0 +1,80 @@
package com.wrbug.polymarketbot.util
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.nio.charset.StandardCharsets
import java.util.*
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
/**
* 加密工具类
* 用于加密存储私钥和 API Key
*/
@Component
class CryptoUtils {
private val logger = LoggerFactory.getLogger(CryptoUtils::class.java)
@Value("\${crypto.secret.key:}")
private var secretKey: String = ""
private val algorithm = "AES"
private val transformation = "AES"
/**
* 获取密钥字节数组
* 使用 SHA-256 哈希从任意长度的密钥生成固定 32 字节的密钥(AES-256)
*/
private fun getKeyBytes(): ByteArray {
val rawKey = if (secretKey.isEmpty()) {
logger.warn("未配置加密密钥,使用默认密钥(仅用于开发环境)")
"default-secret-key-32-bytes-long!!"
} else {
secretKey
}
// 将原始密钥转换为字节数组
val keyBytes = rawKey.toByteArray(StandardCharsets.UTF_8)
// 使用 SHA-256 哈希生成固定 32 字节的密钥(AES-256)
val messageDigest = java.security.MessageDigest.getInstance("SHA-256")
return messageDigest.digest(keyBytes)
}
/**
* 加密字符串
*/
fun encrypt(plainText: String): String {
return try {
val keyBytes = getKeyBytes()
val key = SecretKeySpec(keyBytes, algorithm)
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, key)
val encrypted = cipher.doFinal(plainText.toByteArray(StandardCharsets.UTF_8))
Base64.getEncoder().encodeToString(encrypted)
} catch (e: Exception) {
logger.error("加密失败", e)
throw RuntimeException("加密失败: ${e.message}", e)
}
}
/**
* 解密字符串
*/
fun decrypt(encryptedText: String): String {
return try {
val keyBytes = getKeyBytes()
val key = SecretKeySpec(keyBytes, algorithm)
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.DECRYPT_MODE, key)
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedText))
String(decrypted, StandardCharsets.UTF_8)
} catch (e: Exception) {
logger.error("解密失败", e)
throw RuntimeException("解密失败: ${e.message}", e)
}
}
}
@@ -0,0 +1,65 @@
package com.wrbug.polymarketbot.util
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
/**
* 日期工具类
* 用于处理日期字符串和时间戳之间的转换
*/
object DateUtils {
/**
* ISO 8601 日期时间格式化器
*/
private val isoFormatter = DateTimeFormatter.ISO_DATE_TIME
/**
* 将 ISO 8601 格式的日期字符串转换为时间戳(毫秒)
* @param dateString ISO 8601 格式的日期字符串,如 "2020-11-04T00:00:00Z"
* @return 时间戳(毫秒),如果转换失败返回 null
*/
fun parseToTimestamp(dateString: String?): Long? {
if (dateString.isNullOrBlank()) {
return null
}
return try {
// 尝试解析 ISO 8601 格式
val instant = Instant.parse(dateString)
instant.toEpochMilli()
} catch (e: DateTimeParseException) {
// 如果解析失败,尝试其他格式
try {
// 尝试使用 ISO_DATE_TIME 格式化器
val dateTime = java.time.ZonedDateTime.parse(dateString, isoFormatter)
dateTime.toInstant().toEpochMilli()
} catch (e2: Exception) {
// 所有解析都失败,返回 null
null
}
} catch (e: Exception) {
null
}
}
/**
* 将时间戳(毫秒)转换为 ISO 8601 格式的日期字符串
* @param timestamp 时间戳(毫秒)
* @return ISO 8601 格式的日期字符串
*/
fun formatFromTimestamp(timestamp: Long?): String? {
if (timestamp == null) {
return null
}
return try {
val instant = Instant.ofEpochMilli(timestamp)
instant.toString()
} catch (e: Exception) {
null
}
}
}
@@ -0,0 +1,56 @@
package com.wrbug.polymarketbot.util
import org.bouncycastle.crypto.digests.KeccakDigest
import java.math.BigInteger
/**
* Ethereum 工具类
* 用于计算函数签名、编码参数等
*/
object EthereumUtils {
/**
* 计算函数选择器(前4个字节)
* @param functionSignature 函数签名,例如 "computeProxyAddress(address)"
* @return 函数选择器,例如 "0x12345678"
*/
fun getFunctionSelector(functionSignature: String): String {
val hash = keccak256(functionSignature.toByteArray())
return "0x" + hash.substring(0, 8)
}
/**
* 编码地址参数(32字节,左对齐)
* @param address 地址,例如 "0x1234..."
* @return 编码后的地址,64个十六进制字符
*/
fun encodeAddress(address: String): String {
val cleanAddress = address.removePrefix("0x").lowercase()
return cleanAddress.padStart(64, '0')
}
/**
* 从合约调用结果中解析地址
* @param hexResult 十六进制结果
* @return 地址字符串
*/
fun decodeAddress(hexResult: String): String {
val cleanHex = hexResult.removePrefix("0x")
// 地址是最后20字节(40个十六进制字符)
val addressHex = cleanHex.takeLast(40)
return "0x$addressHex"
}
/**
* 计算 Keccak-256 哈希(Ethereum 标准)
* 使用 BouncyCastle 库实现真正的 Keccak-256
*/
private fun keccak256(data: ByteArray): String {
val digest = KeccakDigest(256)
digest.update(data, 0, data.size)
val hash = ByteArray(digest.digestSize)
digest.doFinal(hash, 0)
return hash.joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,32 @@
package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
/**
* JSON 工具类
* 用于解析 JSON 字符串
*/
object JsonUtils {
private val gson = Gson()
/**
* 解析 JSON 字符串数组
* @param jsonString JSON 字符串,如 "[\"Yes\", \"No\"]"
* @return 字符串列表,如果解析失败返回空列表
*/
fun parseStringArray(jsonString: String?): List<String> {
if (jsonString.isNullOrBlank()) {
return emptyList()
}
return try {
val listType = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(jsonString, listType) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
}
@@ -0,0 +1,138 @@
package com.wrbug.polymarketbot.util
import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
/**
* BigDecimal乘法扩展函数
* 安全地将BigDecimal与任意数值类型相乘
* @param value 乘数,支持BigDecimal、BigInteger、Number类型或可转换为BigDecimal的字符串
* @return 乘法结果,如果转换失败返回BigDecimal.ZERO
*/
fun BigDecimal.multi(value: Any): BigDecimal {
kotlin.runCatching {
if (value is BigDecimal) {
return multiply(value)
}
if (value is BigInteger) {
return multiply(value.toBigDecimal())
}
if (value is Number) {
return multiply(value.toSafeBigDecimal())
}
return multiply(BigDecimal(value.toString()))
}
return BigDecimal.ZERO
}
/**
* BigDecimal除法扩展函数
* 安全地将BigDecimal与任意数值类型相除
* @param value 除数,支持BigDecimal、BigInteger类型或可转换为BigDecimal的字符串
* @return 除法结果,精度为18位小数,使用四舍五入模式,如果转换失败返回IllegalBigDecimal
*/
fun BigDecimal.div(value: Any): BigDecimal {
kotlin.runCatching {
if (value is BigDecimal) {
return divide(value, 18, RoundingMode.HALF_UP).stripTrailingZeros()
}
if (value is BigInteger) {
return divide(value.toSafeBigDecimal(), 18, RoundingMode.HALF_UP).stripTrailingZeros()
}
return divide(BigDecimal(value.toString()), 18, RoundingMode.HALF_UP).stripTrailingZeros()
}
return IllegalBigDecimal
}
/**
* BigInteger乘法扩展函数
* 将BigInteger转换为BigDecimal后与任意数值类型相乘
* @param value 乘数,支持任意可转换为BigDecimal的类型
* @return 乘法结果,如果转换失败返回IllegalBigDecimal
*/
fun BigInteger.multi(value: Any): BigDecimal {
val v = this.toBigDecimal()
return runCatching {
v.multi(value)
}.getOrDefault(IllegalBigDecimal)
}
/**
* 大于比较扩展函数
* 安全地比较两个任意类型的数值大小
* @param target 比较目标值
* @return 如果当前值大于目标值返回true,否则返回falsenull值返回false
*/
fun Any?.gt(target: Any?): Boolean {
if (this == null || target == null) {
return false
}
return this.toSafeBigDecimal() > target.toSafeBigDecimal()
}
/**
* 大于等于比较扩展函数
* 安全地比较两个任意类型的数值大小
* @param target 比较目标值
* @return 如果当前值大于等于目标值返回true,否则返回falsenull值返回false
*/
fun Any?.gte(target: Any?): Boolean {
if (this == null || target == null) {
return false
}
return this.toSafeBigDecimal() >= target.toSafeBigDecimal()
}
/**
* 小于比较扩展函数
* 安全地比较两个任意类型的数值大小
* @param target 比较目标值
* @return 如果当前值小于目标值返回true,否则返回falsenull值返回false
*/
fun Any?.lt(target: Any?): Boolean {
if (this == null || target == null) {
return false
}
return this.toSafeBigDecimal() < target.toSafeBigDecimal()
}
/**
* 小于等于比较扩展函数
* 安全地比较两个任意类型的数值大小
* @param target 比较目标值
* @return 如果当前值小于等于目标值返回true,否则返回falsenull值返回false
*/
fun Any?.lte(target: Any?): Boolean {
if (this == null || target == null) {
return false
}
return this.toSafeBigDecimal() <= target.toSafeBigDecimal()
}
/**
* 等于比较扩展函数
* 安全地比较两个任意类型的数值是否相等
* @param target 比较目标值
* @return 如果当前值等于目标值返回true,否则返回falsenull值返回false
*/
fun Any?.eq(target: Any?): Boolean {
if (this == null || target == null) {
return false
}
return this.toSafeBigDecimal() == target.toSafeBigDecimal()
}
/**
* 不等于比较扩展函数
* 安全地比较两个任意类型的数值是否不相等
* @param target 比较目标值
* @return 如果当前值不等于目标值返回true,否则返回falsenull值返回false
*/
fun Any?.neq(target: Any?): Boolean {
if (this == null || target == null) {
return false
}
return this.toSafeBigDecimal() != target.toSafeBigDecimal()
}
@@ -0,0 +1,85 @@
package com.wrbug.polymarketbot.util
import okhttp3.Credentials
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.*
/**
* 创建OkHttpClient客户端
* @return OkHttpClient.Builder
*/
fun createClient() = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.httpProxy("127.0.0.1", 8888)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
/**
* 为OkHttpClient添加HTTP代理支持
* @param hostname 代理服务器地址
* @param port 代理服务器端口
* @param user 代理用户名(可选)
* @param password 代理密码(可选)
* @return OkHttpClient.Builder
*/
fun OkHttpClient.Builder.httpProxy(
hostname: String, port: Int, user: String = "", password: String = ""
): OkHttpClient.Builder {
if (getEnv("ENABLE_PROXY") != "1") {
return this
}
return apply {
proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(hostname, port)))
createSSLSocketFactory()
if (user.isNotEmpty() && password.isNotEmpty()) {
proxyAuthenticator { _, res ->
val credential: String = Credentials.basic(user, password)
res.request.newBuilder().header("Proxy-Authorization", credential).build()
}
}
}
}
/**
* 为OkHttpClient创建信任所有证书的SSL工厂
* @return OkHttpClient.Builder
*/
fun OkHttpClient.Builder.createSSLSocketFactory(): OkHttpClient.Builder {
runCatching {
val sc: SSLContext = SSLContext.getInstance("TLS")
sc.init(null, arrayOf<TrustManager>(TrustAllManager()), SecureRandom())
this.sslSocketFactory(sc.socketFactory, TrustAllManager())
}
return this
}
/**
* 信任所有证书的TrustManager
*/
class TrustAllManager : X509TrustManager {
@Throws(CertificateException::class)
override fun checkClientTrusted(chain: Array<X509Certificate?>?, authType: String?) {
}
@Throws(CertificateException::class)
override fun checkServerTrusted(chain: Array<X509Certificate?>?, authType: String?) {
}
override fun getAcceptedIssuers() = arrayOfNulls<X509Certificate>(0)
}
/**
* 信任所有主机名的HostnameVerifier
*/
class TrustAllHostnameVerifier : HostnameVerifier {
override fun verify(hostname: String?, session: SSLSession?): Boolean {
return true
}
}
@@ -0,0 +1,77 @@
package com.wrbug.polymarketbot.util
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import java.io.IOException
import java.time.Instant
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.Base64
/**
* Polymarket API 认证拦截器
* 实现 L2 认证(使用 API Key、Secret、Passphrase
*
* 认证方式:
* 1. 生成时间戳(秒)
* 2. 使用 Secret 对 (timestamp + method + path + body) 进行 HMAC-SHA256 签名
* 3. 在请求头中添加:
* - X-API-KEY: API Key
* - X-API-SIGN: Base64 编码的签名
* - X-API-TIMESTAMP: 时间戳
* - X-API-PASSPHRASE: Passphrase
*/
class PolymarketAuthInterceptor(
private val apiKey: String,
private val apiSecret: String,
private val apiPassphrase: String
) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
// 生成时间戳(秒)
val timestamp = Instant.now().epochSecond.toString()
// 构建签名字符串: timestamp + method + path + body
val method = originalRequest.method
val path = originalRequest.url.encodedPath + if (originalRequest.url.query != null) "?${originalRequest.url.query}" else ""
// 读取请求体(如果存在)
val body = originalRequest.body?.let { requestBody ->
val buffer = Buffer()
requestBody.writeTo(buffer)
buffer.readUtf8()
} ?: ""
val signString = "$timestamp$method$path$body"
// 使用 HMAC-SHA256 生成签名
val signature = generateSignature(signString, apiSecret)
// 构建新的请求,添加认证头
val newRequest = originalRequest.newBuilder()
.header("X-API-KEY", apiKey)
.header("X-API-SIGN", signature)
.header("X-API-TIMESTAMP", timestamp)
.header("X-API-PASSPHRASE", apiPassphrase)
.build()
return chain.proceed(newRequest)
}
/**
* 使用 HMAC-SHA256 生成签名
*/
private fun generateSignature(message: String, secret: String): String {
val mac = Mac.getInstance("HmacSHA256")
val secretKeySpec = SecretKeySpec(secret.toByteArray(), "HmacSHA256")
mac.init(secretKeySpec)
val hash = mac.doFinal(message.toByteArray())
return Base64.getEncoder().encodeToString(hash)
}
}
@@ -0,0 +1,62 @@
package com.wrbug.polymarketbot.util
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.PolymarketClobApi
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
/**
* Retrofit 客户端工厂
* 用于创建带认证的 Polymarket CLOB API 客户端和 Ethereum RPC API 客户端
*/
@Component
class RetrofitFactory(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String
) {
/**
* 创建带认证的 Polymarket CLOB API 客户端
* @param apiKey API Key
* @param apiSecret API Secret
* @param apiPassphrase API Passphrase
* @return PolymarketClobApi 客户端
*/
fun createClobApi(
apiKey: String,
apiSecret: String,
apiPassphrase: String
): PolymarketClobApi {
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase)
val okHttpClient = createClient()
.addInterceptor(authInterceptor)
.build()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(PolymarketClobApi::class.java)
}
/**
* 创建 Ethereum RPC API 客户端
* @param rpcUrl RPC 节点 URL
* @return EthereumRpcApi 客户端
*/
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
val okHttpClient = createClient().build()
return Retrofit.Builder()
.baseUrl(rpcUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(EthereumRpcApi::class.java)
}
}
@@ -0,0 +1,97 @@
package com.wrbug.polymarketbot.util
import java.math.BigDecimal
import java.math.BigInteger
/**
* 非法的BigDecimal常量,用于表示转换失败的情况
*/
val IllegalBigDecimal = BigDecimal("0")
/**
* 非法的BigInteger常量,用于表示转换失败的情况
*/
val IllegalBigInteger = BigInteger("0")
/**
* 安全转换为BigDecimal的扩展函数
* 将任意类型安全地转换为BigDecimal,转换失败时返回IllegalBigDecimal
* @return 转换后的BigDecimal值,失败时返回IllegalBigDecimal
*/
fun Any?.toSafeBigDecimal(): BigDecimal {
return try {
if (this is BigDecimal) {
return this
}
if (this is BigInteger) {
return this.toBigDecimal()
}
if (this is Number) {
return BigDecimal.valueOf(this.toDouble())
}
BigDecimal(this.toString())
} catch (t: Throwable) {
IllegalBigDecimal
}
}
/**
* 安全转换为BigInteger的扩展函数
* 将字符串安全地转换为BigInteger,转换失败时返回IllegalBigInteger
* @return 转换后的BigInteger值,失败时返回IllegalBigInteger
*/
fun String?.toSafeBigInteger(): BigInteger {
return try {
BigInteger(this.orEmpty())
} catch (t: Throwable) {
IllegalBigInteger
}
}
/**
* 安全转换为Long的扩展函数
* 将字符串安全地转换为Long,转换失败时返回0
* @return 转换后的Long值,失败时返回0
*/
fun String?.toSafeLong(): Long {
return try {
this?.toLong() ?: 0
} catch (t: Throwable) {
0
}
}
/**
* 安全转换为Int的扩展函数
* 将任意类型安全地转换为Int,转换失败时返回0
* @return 转换后的Int值,失败时返回0
*/
fun Any?.toSafeInt(): Int {
return try {
if (this is Number) {
this.toInt()
} else {
this?.toString().toSafeBigDecimal().toInt()
}
} catch (t: Throwable) {
0
}
}
/**
* 安全转换为Double的扩展函数
* 将任意类型安全地转换为Double,转换失败时返回0.0
* @return 转换后的Double值,失败时返回0.0
*/
fun Any?.toSafeDouble(): Double {
return try {
when (this) {
is Number -> this.toDouble()
is Boolean -> if (this) 1.0 else 0.0
else -> this?.toString().toSafeBigDecimal().toDouble()
}
} catch (t: Throwable) {
0.0
}
}
@@ -0,0 +1,9 @@
package com.wrbug.polymarketbot.util
/**
* 获取环境变量的扩展函数
* @param name 环境变量名称
* @return 环境变量值,不存在时返回空字符串
*/
fun getEnv(name: String) = System.getenv(name).orEmpty()
@@ -0,0 +1,77 @@
package com.wrbug.polymarketbot.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import org.java_websocket.client.WebSocketClient
import org.java_websocket.handshake.ServerHandshake
import org.slf4j.LoggerFactory
import java.net.URI
/**
* Polymarket WebSocket 客户端
* 用于连接到 Polymarket RTDS
*/
class PolymarketWebSocketClient(
serverUri: URI,
private val objectMapper: ObjectMapper,
private val sessionId: String,
private val onMessage: (String) -> Unit
) : WebSocketClient(serverUri) {
private val logger = LoggerFactory.getLogger(PolymarketWebSocketClient::class.java)
override fun onOpen(handshakedata: ServerHandshake?) {
logger.info("已成功连接到 Polymarket RTDS: $sessionId")
}
override fun onMessage(message: String?) {
if (message != null) {
logger.debug("收到 Polymarket 消息: $sessionId, $message")
onMessage(message)
}
}
override fun onClose(code: Int, reason: String?, remote: Boolean) {
logger.info("Polymarket 连接关闭: $sessionId, code: $code, reason: $reason, remote: $remote")
}
/**
* 关闭连接
*/
fun closeConnection() {
if (isOpen) {
try {
closeBlocking()
} catch (e: Exception) {
logger.error("关闭连接失败: $sessionId, ${e.message}", e)
}
}
}
override fun onError(ex: Exception?) {
logger.error("Polymarket WebSocket 错误: $sessionId, ${ex?.message}", ex)
}
/**
* 发送消息到 Polymarket
*/
fun sendMessage(message: String) {
if (isOpen) {
try {
send(message)
} catch (e: Exception) {
logger.error("发送消息失败: $sessionId, ${e.message}", e)
throw e
}
} else {
logger.warn("WebSocket 未连接,无法发送消息: $sessionId")
}
}
/**
* 检查连接状态
*/
fun isConnected(): Boolean {
return isOpen
}
}
@@ -0,0 +1,145 @@
package com.wrbug.polymarketbot.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.socket.*
import java.net.URI
import java.util.concurrent.ConcurrentHashMap
/**
* Polymarket WebSocket 处理器
* 转发前端 WebSocket 连接到 Polymarket RTDS
*/
@Component
class PolymarketWebSocketHandler(
private val objectMapper: ObjectMapper
) : WebSocketHandler {
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
@Value("\${polymarket.rtds.ws-url}")
private lateinit var polymarketWsUrl: String
// 存储客户端会话和对应的 Polymarket 连接的映射
private val clientSessions = ConcurrentHashMap<String, WebSocketSession>()
private val polymarketConnections = ConcurrentHashMap<String, PolymarketWebSocketClient>()
override fun afterConnectionEstablished(session: WebSocketSession) {
logger.info("客户端连接建立: ${session.id}")
clientSessions[session.id] = session
try {
// 创建到 Polymarket 的 WebSocket 连接
val polymarketClient = PolymarketWebSocketClient(
URI(polymarketWsUrl),
objectMapper,
session.id
) { message ->
// 当收到 Polymarket 消息时,转发给客户端
forwardToClient(session.id, message)
}
polymarketConnections[session.id] = polymarketClient
// 异步连接,不阻塞
try {
polymarketClient.connect()
logger.info("正在连接到 Polymarket RTDS: ${session.id}")
} catch (e: Exception) {
logger.error("启动 Polymarket 连接失败: ${e.message}", e)
// 连接失败时清理资源
cleanup(session.id)
try {
session.close(CloseStatus.SERVER_ERROR.withReason("无法连接到 Polymarket"))
} catch (ex: Exception) {
logger.error("关闭客户端连接失败: ${ex.message}", ex)
}
}
} catch (e: Exception) {
logger.error("创建 Polymarket 客户端失败: ${e.message}", e)
try {
session.close(CloseStatus.SERVER_ERROR.withReason("无法创建连接"))
} catch (ex: Exception) {
logger.error("关闭客户端连接失败: ${ex.message}", ex)
}
}
}
override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) {
logger.debug("收到客户端消息: ${session.id}, ${message.payload}")
val polymarketClient = polymarketConnections[session.id]
if (polymarketClient != null) {
if (polymarketClient.isConnected()) {
// 将客户端消息转发给 Polymarket
try {
polymarketClient.sendMessage(message.payload.toString())
} catch (e: Exception) {
logger.error("转发消息到 Polymarket 失败: ${e.message}", e)
}
} else {
logger.warn("Polymarket 连接未就绪,消息将被丢弃: ${session.id}")
}
} else {
logger.warn("Polymarket 连接不存在: ${session.id}")
}
}
override fun handleTransportError(session: WebSocketSession, exception: Throwable) {
logger.error("WebSocket 传输错误: ${session.id}, ${exception.message}", exception)
cleanup(session.id)
}
override fun afterConnectionClosed(session: WebSocketSession, closeStatus: CloseStatus) {
logger.info("客户端连接关闭: ${session.id}, 状态: $closeStatus")
cleanup(session.id)
}
override fun supportsPartialMessages(): Boolean {
return false
}
/**
* 转发消息给客户端
*/
private fun forwardToClient(sessionId: String, message: String) {
val session = clientSessions[sessionId]
if (session != null && session.isOpen) {
try {
session.sendMessage(TextMessage(message))
} catch (e: Exception) {
logger.error("转发消息给客户端失败: ${sessionId}, ${e.message}", e)
}
} else {
logger.warn("客户端会话不存在或已关闭: $sessionId")
}
}
/**
* 清理资源
*/
private fun cleanup(sessionId: String) {
try {
// 关闭 Polymarket 连接
val polymarketClient = polymarketConnections.remove(sessionId)
if (polymarketClient != null) {
try {
if (polymarketClient.isConnected()) {
polymarketClient.closeConnection()
}
} catch (e: Exception) {
logger.error("关闭 Polymarket 连接失败: ${sessionId}, ${e.message}", e)
}
}
// 移除客户端会话
clientSessions.remove(sessionId)
logger.debug("已清理资源: $sessionId")
} catch (e: Exception) {
logger.error("清理资源时发生错误: ${sessionId}, ${e.message}", e)
}
}
}
@@ -0,0 +1,45 @@
# 应用配置
spring.application.name=polymarket-bot-backend
# 数据源配置
spring.datasource.url=jdbc:mysql://localhost:3306/polymarket_bot?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
spring.datasource.username=${DB_USERNAME:root}
spring.datasource.password=${DB_PASSWORD:11111111}
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
# Polymarket API 配置
polymarket.clob.base-url=https://clob.polymarket.com
polymarket.rtds.ws-url=wss://ws-live-data.polymarket.com
polymarket.data-api.base-url=https://data-api.polymarket.com
# Ethereum RPC 配置(用于查询链上余额)
# 可选:如果未配置,将无法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓
# 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID
ethereum.rpc.url=${ETHEREUM_RPC_URL:https://polygon-rpc.com}
# 加密配置
crypto.secret.key=${CRYPTO_SECRET_KEY:wrbug123}
@@ -0,0 +1,14 @@
-- 创建账户表
CREATE TABLE IF NOT EXISTS copy_trading_accounts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
private_key VARCHAR(500) NOT NULL COMMENT '私钥(加密存储)',
wallet_address VARCHAR(42) NOT NULL UNIQUE COMMENT '钱包地址(从私钥推导)',
api_key VARCHAR(500) NULL COMMENT 'Polymarket API Key(可选,加密存储)',
account_name VARCHAR(100) NULL COMMENT '账户名称',
is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户',
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
INDEX idx_wallet_address (wallet_address),
INDEX idx_is_default (is_default)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单系统账户表';
@@ -0,0 +1,5 @@
-- 添加 API Secret 和 Passphrase 字段
ALTER TABLE copy_trading_accounts
ADD COLUMN api_secret VARCHAR(500) NULL COMMENT 'Polymarket API Secret(可选,加密存储)' AFTER api_key,
ADD COLUMN api_passphrase VARCHAR(500) NULL COMMENT 'Polymarket API Passphrase(可选,加密存储)' AFTER api_secret;
@@ -0,0 +1,4 @@
-- 添加代理地址字段到账户表
ALTER TABLE copy_trading_accounts
ADD COLUMN proxy_address VARCHAR(42) NOT NULL COMMENT 'Polymarket 代理钱包地址(从合约获取,必须)' AFTER wallet_address;
Submodule
+1
Submodule clob-client added at 96e25998b8
+550
View File
@@ -0,0 +1,550 @@
# 跟单买入和卖出实现方案
## 1. 核心思路
### 1.1 基本原理
- **买入跟单**:当 Leader 执行 `BUY` 交易时,系统自动创建 `BUY` 订单
- **卖出跟单**:当 Leader 执行 `SELL` 交易时,系统自动创建 `SELL` 订单
- **方向复制**:直接复制 Leader 交易的 `side` 字段(BUY 或 SELL
### 1.2 数据来源
- **方式1(优先)**WebSocket 推送(RTDS API
- 实时接收 Leader 的交易推送
- WebSocket URL: `wss://ws-live-data.polymarket.com`
- 订阅用户交易频道,实时获取交易数据
- **方式2(备选)**:轮询 CLOB API
- 通过 CLOB API `/trades?user={leaderAddress}` 获取 Leader 的交易记录
- 定期轮询(默认每 5 秒)
**交易数据包含**
- `side`: "BUY" 或 "SELL"(直接复制)
- `market`: 市场地址(直接复制)
- `price`: 交易价格(可调整)
- `size`: 交易数量(按比例或固定金额计算)
## 2. 实现流程
### 2.1 监控 Leader 交易
#### 2.1.1 WebSocket 推送模式(优先)
```kotlin
/**
* WebSocket 推送监控服务
*/
@Service
class CopyTradingWebSocketService(
private val leaderRepository: LeaderRepository,
private val configRepository: CopyTradingConfigRepository
) {
private var webSocketClient: WebSocketClient? = null
private val subscribedLeaders = mutableSetOf<String>()
/**
* 初始化 WebSocket 连接
*/
@PostConstruct
fun initWebSocket() {
val config = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig()
if (config.useWebSocket) {
connectWebSocket()
}
}
/**
* 连接 WebSocket
*/
private fun connectWebSocket() {
try {
val wsUrl = "wss://ws-live-data.polymarket.com"
webSocketClient = WebSocketClient(wsUrl)
webSocketClient?.onMessage { message ->
handleWebSocketMessage(message)
}
webSocketClient?.onError { error ->
logger.error("WebSocket 连接错误", error)
// 降级到轮询模式
fallbackToPolling()
}
webSocketClient?.onClose {
logger.warn("WebSocket 连接关闭,尝试重连")
reconnectWebSocket()
}
// 订阅所有启用的 Leader
subscribeAllLeaders()
} catch (e: Exception) {
logger.error("WebSocket 连接失败,降级到轮询模式", e)
fallbackToPolling()
}
}
/**
* 订阅所有启用的 Leader
*/
private fun subscribeAllLeaders() {
val enabledLeaders = leaderRepository.findByEnabledTrue()
enabledLeaders.forEach { leader ->
subscribeLeader(leader.leaderAddress)
}
}
/**
* 订阅单个 Leader
*/
fun subscribeLeader(leaderAddress: String) {
val subscribeMessage = jsonObjectOf(
"type" to "subscribe",
"channel" to "user",
"user" to leaderAddress
)
webSocketClient?.send(subscribeMessage.toString())
subscribedLeaders.add(leaderAddress)
}
/**
* 处理 WebSocket 消息
*/
private fun handleWebSocketMessage(message: String) {
try {
val json = JSONObject(message)
val channel = json.optString("channel")
val eventType = json.optString("event")
if (channel == "user" && eventType == "trade") {
val trade = parseTradeMessage(json)
// 触发跟单逻辑
processTradeFromWebSocket(trade)
}
} catch (e: Exception) {
logger.error("处理 WebSocket 消息失败", e)
}
}
/**
* 重连 WebSocket
*/
private fun reconnectWebSocket() {
val config = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig()
var retryCount = 0
while (retryCount < config.websocketMaxRetries) {
try {
Thread.sleep(config.websocketReconnectInterval.toLong())
connectWebSocket()
return
} catch (e: Exception) {
retryCount++
logger.warn("WebSocket 重连失败 (${retryCount}/${config.websocketMaxRetries})", e)
}
}
// 重连失败,降级到轮询
logger.error("WebSocket 重连失败,降级到轮询模式")
fallbackToPolling()
}
}
```
#### 2.1.2 轮询模式(备选)
```kotlin
/**
* 轮询监控服务(WebSocket 不可用时的备选方案)
*/
@Service
class CopyTradingPollingService(
private val clobService: PolymarketClobService,
private val leaderRepository: LeaderRepository,
private val configRepository: CopyTradingConfigRepository
) {
/**
* 定期轮询所有启用的 Leader 的交易
*/
@Scheduled(fixedDelayString = "\${copy.trading.poll.interval:5000}")
suspend fun monitorLeaders() {
val config = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig()
// 如果配置了使用 WebSocket 且 WebSocket 可用,则不轮询
if (config.useWebSocket && isWebSocketAvailable()) {
return
}
val enabledLeaders = leaderRepository.findByEnabledTrue()
enabledLeaders.forEach { leader ->
try {
processLeaderTrades(leader)
} catch (e: Exception) {
logger.error("处理 Leader ${leader.id} 交易失败", e)
}
}
}
/**
* 处理单个 Leader 的交易
*/
private suspend fun processLeaderTrades(leader: Leader) {
// 1. 获取 Leader 的最新交易记录
val tradesResult = clobService.getTrades(
market = null,
user = leader.leaderAddress,
limit = 50,
offset = 0
)
tradesResult.fold(
onSuccess = { trades ->
trades.forEach { trade ->
// 2. 检查是否已处理过(去重)
if (!isProcessed(leader.id, trade.id)) {
// 3. 处理交易(买入或卖出)
processTrade(leader, trade)
// 4. 标记为已处理
markAsProcessed(leader.id, trade.id)
}
}
},
onFailure = { e ->
logger.error("获取 Leader ${leader.id} 交易失败", e)
}
)
}
}
```
### 2.2 处理交易(买入/卖出)
```kotlin
/**
* 处理单笔交易,自动识别买入或卖出
*/
private suspend fun processTrade(leader: Leader, trade: TradeResponse) {
// 1. 验证分类筛选
if (leader.category != null) {
val marketCategory = getMarketCategory(trade.market)
if (marketCategory != leader.category) {
logger.debug("跳过交易:分类不匹配 ${trade.market}")
return
}
}
// 2. 验证风险控制
if (!checkRiskControl(leader)) {
logger.warn("风险控制限制,跳过跟单 Leader ${leader.id}")
return
}
// 3. 确定使用的账户
val account = getAccountForLeader(leader)
if (account == null) {
logger.error("无法获取账户,跳过跟单 Leader ${leader.id}")
return
}
// 4. 计算跟单订单参数
val orderParams = calculateOrderParams(leader, trade)
// 5. 创建跟单订单(买入或卖出)
createCopyOrder(leader, account, trade, orderParams)
}
/**
* 计算跟单订单参数
*/
private fun calculateOrderParams(leader: Leader, trade: TradeResponse): OrderParams {
// 获取配置
val globalConfig = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig()
// 计算订单大小
val leaderSize = trade.size.toSafeBigDecimal()
val copyRatio = leader.copyRatio ?: globalConfig.copyRatio
var orderSize = leaderSize.multiply(copyRatio)
// 应用限制
val maxSize = leader.maxOrderSize ?: globalConfig.maxOrderSize
val minSize = leader.minOrderSize ?: globalConfig.minOrderSize
orderSize = orderSize.coerceIn(minSize, maxSize)
// 计算价格(默认使用 Leader 的价格)
val price = trade.price.toSafeBigDecimal()
// TODO: 可以根据价格容忍度调整价格
return OrderParams(
market = trade.market,
side = trade.side, // 直接复制 BUY 或 SELL
price = price,
size = orderSize
)
}
data class OrderParams(
val market: String,
val side: String, // "BUY" 或 "SELL"
val price: BigDecimal,
val size: BigDecimal
)
```
### 2.3 创建跟单订单
```kotlin
/**
* 创建跟单订单(买入或卖出)
*/
private suspend fun createCopyOrder(
leader: Leader,
account: Account,
trade: TradeResponse,
params: OrderParams
) {
try {
// 1. 创建订单请求
val orderRequest = CreateOrderRequest(
market = params.market,
side = params.side, // "BUY" 或 "SELL"
price = params.price.toPlainString(),
size = params.size.toPlainString(),
type = "LIMIT"
)
// 2. 使用账户的 API Key 创建订单
val apiKey = account.apiKey ?: throw IllegalStateException("账户 ${account.id} 未配置 API Key")
val clobApi = createClobApiWithApiKey(apiKey)
val orderResult = clobApi.createOrder(orderRequest)
orderResult.fold(
onSuccess = { orderResponse ->
// 3. 保存跟单记录
val copyOrder = CopyOrder(
accountId = account.id!!,
leaderId = leader.id!!,
leaderAddress = leader.leaderAddress,
leaderTradeId = trade.id,
marketId = params.market,
category = getMarketCategory(params.market),
side = params.side, // 保存买入或卖出方向
price = params.price,
size = params.size,
copyRatio = leader.copyRatio ?: BigDecimal.ONE,
orderId = orderResponse.id,
status = "created"
)
copyOrderRepository.save(copyOrder)
logger.info("成功创建跟单订单: Leader=${leader.id}, Side=${params.side}, Market=${params.market}")
},
onFailure = { e ->
logger.error("创建跟单订单失败: Leader=${leader.id}, Side=${params.side}", e)
// 记录失败的跟单订单
val copyOrder = CopyOrder(
accountId = account.id!!,
leaderId = leader.id!!,
leaderAddress = leader.leaderAddress,
leaderTradeId = trade.id,
marketId = params.market,
category = getMarketCategory(params.market),
side = params.side,
price = params.price,
size = params.size,
copyRatio = leader.copyRatio ?: BigDecimal.ONE,
status = "failed"
)
copyOrderRepository.save(copyOrder)
}
)
} catch (e: Exception) {
logger.error("创建跟单订单异常: Leader=${leader.id}, Side=${params.side}", e)
}
}
```
## 3. 关键实现细节
### 3.1 买入和卖出的区别
**买入跟单(BUY**
- Leader 执行 `BUY` 交易 → 系统创建 `BUY` 订单
- 订单参数:`side = "BUY"`
- 表示买入该市场的 YES 或 NO 代币
**卖出跟单(SELL**
- Leader 执行 `SELL` 交易 → 系统创建 `SELL` 订单
- 订单参数:`side = "SELL"`
- 表示卖出持有的代币
**实现上无区别**
- 买入和卖出的处理逻辑完全相同
- 只是 `side` 字段的值不同("BUY" 或 "SELL"
- 都通过 `createOrder` API 创建订单
### 3.2 去重机制
```kotlin
/**
* 检查交易是否已处理
*/
private suspend fun isProcessed(leaderId: Long, tradeId: String): Boolean {
return processedTradeRepository.existsByLeaderIdAndTradeId(leaderId, tradeId)
}
/**
* 标记交易为已处理
*/
private suspend fun markAsProcessed(leaderId: Long, tradeId: String) {
val processed = ProcessedTrade(
leaderId = leaderId,
tradeId = tradeId,
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
}
```
### 3.3 账户选择
```kotlin
/**
* 获取 Leader 使用的账户
*/
private suspend fun getAccountForLeader(leader: Leader): Account? {
return if (leader.accountId != null) {
// 使用 Leader 指定的账户
accountRepository.findById(leader.accountId)
} else {
// 使用默认账户
accountRepository.findByIsDefaultTrue()
}
}
```
### 3.4 风险控制检查
```kotlin
/**
* 检查风险控制限制
*/
private suspend fun checkRiskControl(leader: Leader): Boolean {
val config = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig()
// 检查每日亏损限制
val todayLoss = getTodayLoss(leader.accountId ?: getDefaultAccountId())
if (todayLoss >= config.maxDailyLoss) {
logger.warn("达到每日亏损限制: $todayLoss >= ${config.maxDailyLoss}")
return false
}
// 检查每日订单数限制
val todayOrderCount = getTodayOrderCount(leader.accountId ?: getDefaultAccountId())
if (todayOrderCount >= config.maxDailyOrders) {
logger.warn("达到每日订单数限制: $todayOrderCount >= ${config.maxDailyOrders}")
return false
}
return true
}
```
## 4. 数据模型
### 4.1 ProcessedTrade(已处理交易)
```kotlin
@Entity
@Table(name = "copy_trading_processed_trades")
data class ProcessedTrade(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
@Column(name = "trade_id", nullable = false, length = 100)
val tradeId: String,
@Column(name = "processed_at", nullable = false)
val processedAt: Long = System.currentTimeMillis(),
@UniqueConstraint(columnNames = ["leader_id", "trade_id"])
)
```
## 5. 完整示例
### 5.1 买入跟单示例
```
1. Leader 执行买入交易:
- Trade: { id: "trade_123", market: "0x...", side: "BUY", price: "0.5", size: "100" }
2. 系统检测到交易:
- 验证分类、风险控制
- 计算跟单参数:size = 100 × 1.0 = 100
3. 创建跟单订单:
- CreateOrderRequest: { market: "0x...", side: "BUY", price: "0.5", size: "100" }
- 调用 CLOB API 创建订单
4. 保存记录:
- CopyOrder: { side: "BUY", ... }
```
### 5.2 卖出跟单示例
```
1. Leader 执行卖出交易:
- Trade: { id: "trade_456", market: "0x...", side: "SELL", price: "0.6", size: "50" }
2. 系统检测到交易:
- 验证分类、风险控制
- 计算跟单参数:size = 50 × 1.0 = 50
3. 创建跟单订单:
- CreateOrderRequest: { market: "0x...", side: "SELL", price: "0.6", size: "50" }
- 调用 CLOB API 创建订单
4. 保存记录:
- CopyOrder: { side: "SELL", ... }
```
## 6. 注意事项
### 6.1 交易 vs 订单
- **交易(Trade)**:已成交的记录,包含 `side` 字段
- **订单(Order)**:挂单,可能未成交
- 跟单系统基于**交易记录**触发,创建**订单**
### 6.2 价格和数量
- **价格**:默认使用 Leader 的交易价格,可配置价格容忍度
- **数量**:按跟单比例计算,应用最大/最小限制
### 6.3 错误处理
- API 调用失败时记录失败状态
- 网络异常时重试机制
- 记录详细日志便于排查
### 6.4 性能优化
- 批量查询多个 Leader 的交易
- 使用缓存减少重复查询
- 异步处理订单创建
## 7. 总结
**买入和卖出的实现完全相同**
- 都通过监控 Leader 的交易记录触发
- 都通过 `side` 字段区分("BUY" 或 "SELL"
- 都调用相同的 `createOrder` API
- 区别仅在于 `side` 参数的值
**核心流程**
1. 轮询 Leader 交易 → 2. 识别买入/卖出 → 3. 计算参数 → 4. 创建订单 → 5. 保存记录
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+74
View File
@@ -0,0 +1,74 @@
# Polymarket 跟单系统前端
## 技术栈
- **框架**: React 18 + TypeScript
- **构建工具**: Vite
- **UI 库**: Ant Design + Ant Design Mobile
- **HTTP 客户端**: axios
- **状态管理**: Zustand
- **路由**: React Router
- **以太坊库**: ethers.js
## 功能特性
- ✅ 账户管理(通过私钥导入,支持多账户)
- ✅ Leader 管理(被跟单者管理)
- ✅ 跟单配置管理
- ✅ 订单管理
- ✅ 统计信息
- ✅ 移动端适配
## 开发
```bash
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
```
## 项目结构
```
frontend/
├── src/
│ ├── components/ # 公共组件
│ │ └── Layout.tsx # 布局组件(支持移动端)
│ ├── pages/ # 页面组件
│ │ ├── AccountList.tsx
│ │ ├── AccountImport.tsx
│ │ ├── LeaderList.tsx
│ │ ├── LeaderAdd.tsx
│ │ ├── ConfigPage.tsx
│ │ ├── OrderList.tsx
│ │ └── Statistics.tsx
│ ├── services/ # API 服务
│ │ └── api.ts
│ ├── store/ # 状态管理
│ │ └── accountStore.ts
│ ├── types/ # TypeScript 类型定义
│ │ └── index.ts
│ ├── utils/ # 工具函数
│ │ └── ethers.ts
│ ├── styles/ # 样式文件
│ │ └── index.css
│ ├── App.tsx # 根组件
│ └── main.tsx # 入口文件
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.ts
```
## 移动端适配
- 使用 `react-responsive` 检测设备类型
- 移动端使用 Ant Design Mobile 组件
- 响应式布局(移动端 < 768px,桌面端 >= 768px
- 触摸友好的按钮尺寸(>= 44x44px
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Polymarket 跟单系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4917
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "polymarket-bot-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 3000 --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"axios": "^1.6.2",
"zustand": "^4.4.7",
"antd": "^5.12.0",
"antd-mobile": "^5.34.0",
"ethers": "^6.9.0",
"react-responsive": "^9.0.2"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}
+37
View File
@@ -0,0 +1,37 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import Layout from './components/Layout'
import AccountList from './pages/AccountList'
import AccountImport from './pages/AccountImport'
import AccountDetail from './pages/AccountDetail'
import LeaderList from './pages/LeaderList'
import LeaderAdd from './pages/LeaderAdd'
import ConfigPage from './pages/ConfigPage'
import OrderList from './pages/OrderList'
import Statistics from './pages/Statistics'
function App() {
return (
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<AccountList />} />
<Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/import" element={<AccountImport />} />
<Route path="/accounts/detail" element={<AccountDetail />} />
<Route path="/leaders" element={<LeaderList />} />
<Route path="/leaders/add" element={<LeaderAdd />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/orders" element={<OrderList />} />
<Route path="/statistics" element={<Statistics />} />
</Routes>
</Layout>
</BrowserRouter>
</ConfigProvider>
)
}
export default App
+142
View File
@@ -0,0 +1,142 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { Layout as AntLayout, Menu, Drawer, Button } from 'antd'
import { useMediaQuery } from 'react-responsive'
import {
WalletOutlined,
UserOutlined,
SettingOutlined,
UnorderedListOutlined,
BarChartOutlined,
MenuOutlined
} from '@ant-design/icons'
import type { ReactNode } from 'react'
const { Header, Content, Sider } = AntLayout
interface LayoutProps {
children: ReactNode
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
const navigate = useNavigate()
const location = useLocation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const menuItems = [
{
key: '/accounts',
icon: <WalletOutlined />,
label: '账户管理'
},
{
key: '/leaders',
icon: <UserOutlined />,
label: 'Leader 管理'
},
{
key: '/config',
icon: <SettingOutlined />,
label: '跟单配置'
},
{
key: '/orders',
icon: <UnorderedListOutlined />,
label: '订单管理'
},
{
key: '/statistics',
icon: <BarChartOutlined />,
label: '统计信息'
}
]
const handleMenuClick = (key: string) => {
navigate(key)
if (isMobile) {
setMobileMenuOpen(false)
}
}
if (isMobile) {
// 移动端布局
return (
<AntLayout style={{ minHeight: '100vh' }}>
<Header style={{
background: '#001529',
padding: '0 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>
Polymarket
</div>
<Button
type="text"
icon={<MenuOutlined />}
style={{ color: '#fff' }}
onClick={() => setMobileMenuOpen(true)}
/>
</Header>
<Content style={{
padding: '12px 8px',
background: '#f0f2f5',
minHeight: 'calc(100vh - 64px)'
}}>
{children}
</Content>
<Drawer
title="导航菜单"
placement="left"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
bodyStyle={{ padding: 0 }}
>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => handleMenuClick(key)}
style={{ border: 'none' }}
/>
</Drawer>
</AntLayout>
)
}
// 桌面端布局
return (
<AntLayout style={{ minHeight: '100vh' }}>
<Sider width={200} style={{ background: '#001529' }}>
<div style={{
height: '64px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '18px',
fontWeight: 'bold'
}}>
Polymarket
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => handleMenuClick(key)}
style={{ height: 'calc(100vh - 64px)', borderRight: 0 }}
/>
</Sider>
<AntLayout>
<Content style={{ padding: '24px', background: '#f0f2f5', minHeight: '100vh' }}>
{children}
</Content>
</AntLayout>
</AntLayout>
)
}
export default Layout
+11
View File
@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+254
View File
@@ -0,0 +1,254 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Card, Descriptions, Button, Space, Tag, Spin, message, Typography, Divider } from 'antd'
import { ArrowLeftOutlined, ReloadOutlined, EditOutlined } from '@ant-design/icons'
import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
const AccountDetail: React.FC = () => {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const isMobile = useMediaQuery({ maxWidth: 768 })
const accountId = searchParams.get('id')
const { fetchAccountDetail, fetchAccountBalance } = useAccountStore()
const [account, setAccount] = useState<Account | null>(null)
const [balance, setBalance] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [balanceLoading, setBalanceLoading] = useState(false)
useEffect(() => {
if (accountId) {
loadAccountDetail()
loadBalance()
} else {
message.error('账户ID不能为空')
navigate('/accounts')
}
}, [accountId])
const loadAccountDetail = async () => {
if (!accountId) return
setLoading(true)
try {
const accountData = await fetchAccountDetail(Number(accountId))
setAccount(accountData)
} catch (error: any) {
message.error(error.message || '获取账户详情失败')
navigate('/accounts')
} finally {
setLoading(false)
}
}
const loadBalance = async () => {
if (!accountId) return
setBalanceLoading(true)
try {
const balanceData = await fetchAccountBalance(Number(accountId))
setBalance(balanceData.balance || null)
} catch (error: any) {
console.error('获取余额失败:', error)
// 余额查询失败不显示错误,只显示 "-"
setBalance(null)
} finally {
setBalanceLoading(false)
}
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
</div>
)
}
if (!account) {
return null
}
return (
<div style={{
padding: isMobile ? '0' : undefined,
margin: isMobile ? '0 -8px' : undefined
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: isMobile ? '12px' : '16px',
flexWrap: 'wrap',
gap: '12px',
padding: isMobile ? '0 8px' : '0'
}}>
<Space wrap>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/accounts')}
size={isMobile ? 'middle' : 'large'}
>
</Button>
<Title level={isMobile ? 4 : 2} style={{ margin: 0, fontSize: isMobile ? '16px' : undefined }}>
{account.accountName || `账户 ${account.id}`}
</Title>
</Space>
<Space wrap style={{ width: isMobile ? '100%' : 'auto' }}>
<Button
icon={<ReloadOutlined />}
onClick={loadBalance}
loading={balanceLoading}
size={isMobile ? 'middle' : 'large'}
block={isMobile}
style={isMobile ? { minHeight: '44px' } : undefined}
>
</Button>
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => navigate(`/accounts/edit?id=${account.id}`)}
size={isMobile ? 'middle' : 'large'}
block={isMobile}
style={isMobile ? { minHeight: '44px' } : undefined}
>
</Button>
</Space>
</div>
<Card style={{
margin: isMobile ? '0 -8px' : '0',
borderRadius: isMobile ? '0' : undefined
}}>
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
style={{ fontSize: isMobile ? '14px' : undefined }}
>
<Descriptions.Item label="账户ID">
{account.id}
</Descriptions.Item>
<Descriptions.Item label="账户名称">
{account.accountName || '-'}
</Descriptions.Item>
<Descriptions.Item label="钱包地址" span={isMobile ? 1 : 2}>
<span style={{
fontFamily: 'monospace',
fontSize: isMobile ? '11px' : '14px',
wordBreak: 'break-all',
lineHeight: '1.4',
display: 'block'
}}>
{account.walletAddress}
</span>
</Descriptions.Item>
<Descriptions.Item label="默认账户">
<Tag color={account.isDefault ? 'gold' : 'default'}>
{account.isDefault ? '是' : '否'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="账户余额">
{balanceLoading ? (
<Spin size="small" />
) : balance ? (
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
{balance} USDC
</span>
) : (
<span style={{ color: '#999' }}>-</span>
)}
</Descriptions.Item>
</Descriptions>
</Card>
<Divider />
<Card
title="API 凭证配置"
style={{
marginTop: isMobile ? '12px' : '16px',
margin: isMobile ? '0 -8px' : '0',
borderRadius: isMobile ? '0' : undefined
}}
>
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
style={{ fontSize: isMobile ? '14px' : undefined }}
>
<Descriptions.Item label="API Key">
<Tag color={account.apiKeyConfigured ? 'success' : 'default'}>
{account.apiKeyConfigured ? '已配置' : '未配置'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="API Secret">
<Tag color={account.apiSecretConfigured ? 'success' : 'default'}>
{account.apiSecretConfigured ? '已配置' : '未配置'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="API Passphrase">
<Tag color={account.apiPassphraseConfigured ? 'success' : 'default'}>
{account.apiPassphraseConfigured ? '已配置' : '未配置'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="配置状态">
{account.apiKeyConfigured && account.apiSecretConfigured && account.apiPassphraseConfigured ? (
<Tag color="success"></Tag>
) : (
<Tag color="warning"></Tag>
)}
</Descriptions.Item>
</Descriptions>
</Card>
{account.totalOrders !== undefined || account.totalPnl !== undefined ? (
<>
<Divider style={{ margin: isMobile ? '12px 0' : '16px 0' }} />
<Card
title="交易统计"
style={{
marginTop: isMobile ? '12px' : '16px',
margin: isMobile ? '0 -8px' : '0',
borderRadius: isMobile ? '0' : undefined
}}
>
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
style={{ fontSize: isMobile ? '14px' : undefined }}
>
{account.totalOrders !== undefined && (
<Descriptions.Item label="总订单数">
{account.totalOrders}
</Descriptions.Item>
)}
{account.totalPnl !== undefined && (
<Descriptions.Item label="总盈亏">
<span style={{
fontWeight: 'bold',
color: account.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
}}>
{account.totalPnl} USDC
</span>
</Descriptions.Item>
)}
</Descriptions>
</Card>
</>
) : null}
</div>
)
}
export default AccountDetail
+361
View File
@@ -0,0 +1,361 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Input, Button, message, Typography, Radio, Space, Alert, Checkbox } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { useAccountStore } from '../store/accountStore'
import {
getAddressFromPrivateKey,
getAddressFromMnemonic,
getPrivateKeyFromMnemonic,
isValidWalletAddress,
isValidPrivateKey,
isValidMnemonic
} from '../utils/ethers'
import { useMediaQuery } from 'react-responsive'
const { Title, Text } = Typography
type ImportType = 'privateKey' | 'mnemonic'
const AccountImport: React.FC = () => {
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { importAccount, loading } = useAccountStore()
const [form] = Form.useForm()
const [importType, setImportType] = useState<ImportType>('privateKey')
const [derivedAddress, setDerivedAddress] = useState<string>('')
const [addressError, setAddressError] = useState<string>('')
// 当私钥输入时,自动推导地址
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const privateKey = e.target.value.trim()
if (!privateKey) {
setDerivedAddress('')
setAddressError('')
return
}
// 验证私钥格式
if (!isValidPrivateKey(privateKey)) {
setAddressError('私钥格式不正确(应为64位十六进制字符串)')
setDerivedAddress('')
return
}
try {
const address = getAddressFromPrivateKey(privateKey)
setDerivedAddress(address)
setAddressError('')
// 自动填充钱包地址字段
form.setFieldsValue({ walletAddress: address })
} catch (error: any) {
setAddressError(error.message || '无法从私钥推导地址')
setDerivedAddress('')
}
}
// 当助记词输入时,自动推导地址
const handleMnemonicChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const mnemonic = e.target.value.trim()
if (!mnemonic) {
setDerivedAddress('')
setAddressError('')
return
}
// 验证助记词格式
if (!isValidMnemonic(mnemonic)) {
setAddressError('助记词格式不正确(应为12或24个单词,用空格分隔)')
setDerivedAddress('')
return
}
try {
const address = getAddressFromMnemonic(mnemonic, 0)
setDerivedAddress(address)
setAddressError('')
// 自动填充钱包地址字段
form.setFieldsValue({ walletAddress: address })
} catch (error: any) {
setAddressError(error.message || '无法从助记词推导地址')
setDerivedAddress('')
}
}
const handleSubmit = async (values: any) => {
try {
let privateKey: string
let walletAddress: string
if (importType === 'privateKey') {
// 私钥模式
privateKey = values.privateKey
walletAddress = values.walletAddress
// 验证推导的地址和输入的地址是否一致
if (derivedAddress && walletAddress !== derivedAddress) {
message.error('钱包地址与私钥不匹配')
return
}
} else {
// 助记词模式
if (!values.mnemonic) {
message.error('请输入助记词')
return
}
// 从助记词导出私钥和地址
privateKey = getPrivateKeyFromMnemonic(values.mnemonic, 0)
const derivedAddressFromMnemonic = getAddressFromMnemonic(values.mnemonic, 0)
// 如果用户手动输入了地址,验证是否与推导的地址一致
if (values.walletAddress) {
if (values.walletAddress !== derivedAddressFromMnemonic) {
// 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址)
message.warning(`输入的地址与助记词推导的地址不一致。推导的地址: ${derivedAddressFromMnemonic},将使用推导的地址`)
walletAddress = derivedAddressFromMnemonic
} else {
// 地址匹配,使用用户输入的地址
walletAddress = values.walletAddress
}
} else {
// 如果用户没有输入地址,使用推导的地址
walletAddress = derivedAddressFromMnemonic
}
}
// 验证钱包地址格式
if (!isValidWalletAddress(walletAddress)) {
message.error('钱包地址格式不正确')
return
}
await importAccount({
privateKey: privateKey,
walletAddress: walletAddress,
accountName: values.accountName,
apiKey: values.apiKey,
apiSecret: values.apiSecret,
apiPassphrase: values.apiPassphrase,
isDefault: values.isDefault || false
})
message.success('导入账户成功')
navigate('/accounts')
} catch (error: any) {
message.error(error.message || '导入账户失败')
}
}
return (
<div>
<div style={{ marginBottom: '16px' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/accounts')}
style={{ marginBottom: '16px' }}
>
</Button>
<Title level={2} style={{ margin: 0 }}></Title>
</div>
<Card>
<Alert
message="安全提示"
description="私钥将加密存储在后端,请确保网络连接安全。建议使用 HTTPS 连接。"
type="warning"
showIcon
style={{ marginBottom: '24px' }}
/>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
size={isMobile ? 'middle' : 'large'}
>
<Form.Item label="导入方式">
<Radio.Group
value={importType}
onChange={(e) => {
setImportType(e.target.value)
setDerivedAddress('')
setAddressError('')
form.setFieldsValue({ walletAddress: '' })
}}
>
<Radio value="privateKey"></Radio>
<Radio value="mnemonic"></Radio>
</Radio.Group>
</Form.Item>
{importType === 'privateKey' ? (
<>
<Form.Item
label="私钥"
name="privateKey"
rules={[
{ required: true, message: '请输入私钥' },
{
validator: (_, value) => {
if (!value) return Promise.resolve()
if (!isValidPrivateKey(value)) {
return Promise.reject(new Error('私钥格式不正确(应为64位十六进制字符串)'))
}
return Promise.resolve()
}
}
]}
help={addressError || (derivedAddress ? `推导地址: ${derivedAddress}` : '')}
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
>
<Input.TextArea
rows={3}
placeholder="请输入私钥(64位十六进制字符串,可选0x前缀)"
onChange={handlePrivateKeyChange}
/>
</Form.Item>
<Form.Item
label="钱包地址"
name="walletAddress"
rules={[
{ required: true, message: '请输入钱包地址' },
{
validator: (_, value) => {
if (!value) return Promise.resolve()
if (!isValidWalletAddress(value)) {
return Promise.reject(new Error('钱包地址格式不正确'))
}
if (derivedAddress && value !== derivedAddress) {
return Promise.reject(new Error('钱包地址与私钥不匹配'))
}
return Promise.resolve()
}
}
]}
>
<Input
placeholder="钱包地址(将从私钥自动推导)"
readOnly={!!derivedAddress}
/>
</Form.Item>
</>
) : (
<>
<Form.Item
label="助记词"
name="mnemonic"
rules={[
{ required: true, message: '请输入助记词' },
{
validator: (_, value) => {
if (!value) return Promise.resolve()
if (!isValidMnemonic(value)) {
return Promise.reject(new Error('助记词格式不正确(应为12或24个单词,用空格分隔)'))
}
return Promise.resolve()
}
}
]}
help={addressError || (derivedAddress ? `推导地址: ${derivedAddress}` : '')}
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
>
<Input.TextArea
rows={4}
placeholder="请输入12或24个单词的助记词(用空格分隔)"
onChange={handleMnemonicChange}
/>
</Form.Item>
<Form.Item
label="钱包地址"
name="walletAddress"
rules={[
{ required: true, message: '请输入钱包地址' },
{
validator: (_, value) => {
if (!value) return Promise.resolve()
if (!isValidWalletAddress(value)) {
return Promise.reject(new Error('钱包地址格式不正确'))
}
if (derivedAddress && value !== derivedAddress) {
return Promise.reject(new Error('钱包地址与助记词不匹配'))
}
return Promise.resolve()
}
}
]}
>
<Input
placeholder="钱包地址(将从助记词自动推导)"
readOnly={!!derivedAddress}
/>
</Form.Item>
</>
)}
<Form.Item
label="账户名称"
name="accountName"
>
<Input placeholder="可选,用于标识账户" />
</Form.Item>
<Form.Item
label="API Key"
name="apiKey"
help="Polymarket API Key(可选,用于 L2 API 认证)"
>
<Input.Password placeholder="可选,Polymarket API Key" />
</Form.Item>
<Form.Item
label="API Secret"
name="apiSecret"
help="Polymarket API Secret(可选,用于 HMAC 签名)"
>
<Input.Password placeholder="可选,Polymarket API Secret" />
</Form.Item>
<Form.Item
label="API Passphrase"
name="apiPassphrase"
help="Polymarket API Passphrase(可选,用于加密/解密密钥)"
>
<Input.Password placeholder="可选,Polymarket API Passphrase" />
</Form.Item>
<Form.Item
name="isDefault"
valuePropName="checked"
>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={loading}
size={isMobile ? 'middle' : 'large'}
>
</Button>
<Button onClick={() => navigate('/accounts')}>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default AccountImport
+575
View File
@@ -0,0 +1,575 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider } from 'antd'
import { PlusOutlined, StarOutlined, StarFilled, ReloadOutlined } from '@ant-design/icons'
import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
const AccountList: React.FC = () => {
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, loading, fetchAccounts, deleteAccount, setDefaultAccount, fetchAccountBalance, fetchAccountDetail } = useAccountStore()
const [balanceMap, setBalanceMap] = useState<Record<number, { total: string; available: string; position: string }>>({})
const [balanceLoading, setBalanceLoading] = useState<Record<number, boolean>>({})
const [detailModalVisible, setDetailModalVisible] = useState(false)
const [detailAccount, setDetailAccount] = useState<Account | null>(null)
const [detailBalance, setDetailBalance] = useState<{ total: string; available: string; position: string; positions: any[] } | null>(null)
const [detailBalanceLoading, setDetailBalanceLoading] = useState(false)
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
// 加载所有账户的余额
useEffect(() => {
const loadBalances = async () => {
for (const account of accounts) {
if (!balanceMap[account.id] && !balanceLoading[account.id]) {
setBalanceLoading(prev => ({ ...prev, [account.id]: true }))
try {
const balanceData = await fetchAccountBalance(account.id)
setBalanceMap(prev => ({
...prev,
[account.id]: {
total: balanceData.totalBalance || '0',
available: balanceData.availableBalance || '0',
position: balanceData.positionBalance || '0'
}
}))
} catch (error) {
console.error(`获取账户 ${account.id} 余额失败:`, error)
setBalanceMap(prev => ({
...prev,
[account.id]: { total: '-', available: '-', position: '-' }
}))
} finally {
setBalanceLoading(prev => ({ ...prev, [account.id]: false }))
}
}
}
}
if (accounts.length > 0) {
loadBalances()
}
}, [accounts])
const handleDelete = async (account: Account) => {
try {
await deleteAccount(account.id)
message.success('删除账户成功')
} catch (error: any) {
message.error(error.message || '删除账户失败')
}
}
const handleSetDefault = async (account: Account) => {
try {
await setDefaultAccount(account.id)
message.success('设置默认账户成功')
} catch (error: any) {
message.error(error.message || '设置默认账户失败')
}
}
const handleShowDetail = async (account: Account) => {
try {
setDetailModalVisible(true)
setDetailAccount(account)
setDetailBalance(null)
setDetailBalanceLoading(false)
// 加载详情和余额
try {
const accountDetail = await fetchAccountDetail(account.id)
setDetailAccount(accountDetail)
// 加载余额
setDetailBalanceLoading(true)
try {
const balanceData = await fetchAccountBalance(account.id)
setDetailBalance({
total: balanceData.totalBalance || '0',
available: balanceData.availableBalance || '0',
position: balanceData.positionBalance || '0',
positions: balanceData.positions || []
})
} catch (error) {
console.error('获取余额失败:', error)
setDetailBalance(null)
} finally {
setDetailBalanceLoading(false)
}
} catch (error: any) {
console.error('获取账户详情失败:', error)
message.error(error.message || '获取账户详情失败')
setDetailModalVisible(false)
setDetailAccount(null)
}
} catch (error: any) {
console.error('打开详情失败:', error)
message.error('打开详情失败')
setDetailModalVisible(false)
setDetailAccount(null)
}
}
const handleRefreshDetailBalance = async () => {
if (!detailAccount) return
setDetailBalanceLoading(true)
try {
const balanceData = await fetchAccountBalance(detailAccount.id)
setDetailBalance({
total: balanceData.totalBalance || '0',
available: balanceData.availableBalance || '0',
position: balanceData.positionBalance || '0',
positions: balanceData.positions || []
})
message.success('余额刷新成功')
} catch (error: any) {
message.error(error.message || '刷新余额失败')
} finally {
setDetailBalanceLoading(false)
}
}
const columns = [
{
title: '账户名称',
dataIndex: 'accountName',
key: 'accountName',
render: (text: string, record: Account) => text || `账户 ${record.id}`
},
{
title: '钱包地址',
dataIndex: 'walletAddress',
key: 'walletAddress',
render: (address: string) => (
<span style={{ fontFamily: 'monospace' }}>{address}</span>
)
},
{
title: '默认账户',
dataIndex: 'isDefault',
key: 'isDefault',
render: (isDefault: boolean, record: Account) => (
<Button
type="text"
icon={isDefault ? <StarFilled style={{ color: '#faad14' }} /> : <StarOutlined />}
onClick={() => !isDefault && handleSetDefault(record)}
disabled={isDefault}
/>
)
},
{
title: 'API 凭证',
key: 'apiCredentials',
render: (_: any, record: Account) => {
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
return (
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'}>
{allConfigured ? '完整配置' : partialConfigured ? '部分配置' : '未配置'}
</Tag>
)
}
},
{
title: '余额',
dataIndex: 'balance',
key: 'balance',
render: (_: any, record: Account) => {
if (balanceLoading[record.id]) {
return <Spin size="small" />
}
const balanceObj = balanceMap[record.id]
const balance = balanceObj?.total || record.balance || '-'
return balance && balance !== '-' && typeof balance === 'string' ? `${balance} USDC` : '-'
}
},
{
title: '操作',
key: 'action',
render: (_: any, record: Account) => (
<Space size="small">
<Button
type="link"
size="small"
onClick={() => handleShowDetail(record)}
>
</Button>
<Popconfirm
title="确定要删除这个账户吗?"
description={
record.apiKeyConfigured
? "删除账户前,请确保已取消所有活跃订单。删除后无法恢复,请谨慎操作!"
: "删除后无法恢复,请谨慎操作!"
}
onConfirm={() => handleDelete(record)}
okText="确定删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
)
}
]
const mobileColumns = [
{
title: '账户信息',
key: 'info',
render: (_: any, record: Account) => {
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
return (
<div style={{ padding: '8px 0' }}>
<div style={{
fontWeight: 'bold',
marginBottom: '8px',
fontSize: '16px'
}}>
{record.accountName || `账户 ${record.id}`}
</div>
<div style={{
fontSize: '11px',
color: '#666',
marginBottom: '8px',
wordBreak: 'break-all',
fontFamily: 'monospace',
lineHeight: '1.4'
}}>
{record.walletAddress}
</div>
<div style={{ marginBottom: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
<Tag color={record.isDefault ? 'gold' : 'default'} style={{ margin: 0 }}>
{record.isDefault ? '默认' : '普通'}
</Tag>
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'} style={{ margin: 0 }}>
{allConfigured ? '完整配置' : partialConfigured ? '部分配置' : '未配置'}
</Tag>
</div>
<div style={{
fontSize: '14px',
fontWeight: '500',
color: '#1890ff'
}}>
: {balanceLoading[record.id] ? (
<Spin size="small" style={{ marginLeft: '4px' }} />
) : balanceMap[record.id]?.total && balanceMap[record.id].total !== '-' ? (
`${balanceMap[record.id].total} USDC`
) : (
'-'
)}
</div>
{balanceMap[record.id] && balanceMap[record.id].available !== '-' && (
<div style={{
fontSize: '12px',
color: '#666',
marginTop: '4px'
}}>
: {balanceMap[record.id].available} USDC | : {balanceMap[record.id].position} USDC
</div>
)}
</div>
)
}
},
{
title: '操作',
key: 'action',
width: 100,
render: (_: any, record: Account) => (
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Button
type="primary"
size="small"
block
onClick={() => handleShowDetail(record)}
style={{ minHeight: '32px' }}
>
</Button>
{!record.isDefault && (
<Button
size="small"
block
icon={<StarOutlined />}
onClick={() => handleSetDefault(record)}
style={{ minHeight: '32px' }}
>
</Button>
)}
<Popconfirm
title="确定要删除这个账户吗?"
description={
record.apiKeyConfigured
? "删除账户前,请确保已取消所有活跃订单。删除后无法恢复,请谨慎操作!"
: "删除后无法恢复,请谨慎操作!"
}
onConfirm={() => handleDelete(record)}
okText="确定删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
size="small"
block
danger
style={{ minHeight: '32px' }}
>
</Button>
</Popconfirm>
</Space>
)
}
]
return (
<div style={{
padding: isMobile ? '0' : undefined,
margin: isMobile ? '0 -8px' : undefined
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: isMobile ? '12px' : '16px',
flexWrap: 'wrap',
gap: '12px',
padding: isMobile ? '0 8px' : '0'
}}>
<Title level={isMobile ? 3 : 2} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}>
</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/accounts/import')}
size={isMobile ? 'middle' : 'large'}
block={isMobile}
style={isMobile ? { minHeight: '44px' } : undefined}
>
</Button>
</div>
<Card style={{
margin: isMobile ? '0 -8px' : '0',
borderRadius: isMobile ? '0' : undefined
}}>
{isMobile ? (
<Table
dataSource={accounts}
columns={mobileColumns}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: false,
simple: true,
size: 'small'
}}
scroll={{ x: 'max-content' }}
size="small"
style={{ fontSize: '14px' }}
/>
) : (
<Table
dataSource={accounts}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
pageSize: 20,
showSizeChanger: true
}}
/>
)}
</Card>
{/* 账户详情 Modal */}
<Modal
title={detailAccount ? (detailAccount.accountName || `账户 ${detailAccount.id}`) : '账户详情'}
open={detailModalVisible}
onCancel={() => {
setDetailModalVisible(false)
setDetailAccount(null)
setDetailBalance(null)
}}
footer={[
<Button
key="refresh"
icon={<ReloadOutlined />}
onClick={handleRefreshDetailBalance}
loading={detailBalanceLoading}
disabled={!detailAccount}
>
</Button>,
<Button
key="close"
onClick={() => {
setDetailModalVisible(false)
setDetailAccount(null)
setDetailBalance(null)
}}
>
</Button>
]}
width={isMobile ? '95%' : 800}
style={{ top: isMobile ? 20 : 50 }}
destroyOnClose
maskClosable
closable
>
{detailAccount ? (
<div>
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
>
<Descriptions.Item label="账户ID">
{detailAccount.id}
</Descriptions.Item>
<Descriptions.Item label="账户名称">
{detailAccount.accountName || '-'}
</Descriptions.Item>
<Descriptions.Item label="钱包地址" span={isMobile ? 1 : 2}>
<span style={{
fontFamily: 'monospace',
fontSize: isMobile ? '11px' : '13px',
wordBreak: 'break-all',
lineHeight: '1.4',
display: 'block'
}}>
{detailAccount.walletAddress || '-'}
</span>
</Descriptions.Item>
<Descriptions.Item label="默认账户">
<Tag color={detailAccount.isDefault ? 'gold' : 'default'}>
{detailAccount.isDefault ? '是' : '否'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="总余额" span={isMobile ? 1 : 2}>
{detailBalanceLoading ? (
<Spin size="small" />
) : detailBalance ? (
<span style={{ fontWeight: 'bold', color: '#1890ff', fontSize: '16px' }}>
{detailBalance.total} USDC
</span>
) : (
<span style={{ color: '#999' }}>-</span>
)}
</Descriptions.Item>
<Descriptions.Item label="可用余额">
{detailBalanceLoading ? (
<Spin size="small" />
) : detailBalance ? (
<span style={{ color: '#52c41a' }}>
{detailBalance.available} USDC
</span>
) : (
<span style={{ color: '#999' }}>-</span>
)}
</Descriptions.Item>
<Descriptions.Item label="仓位余额">
{detailBalanceLoading ? (
<Spin size="small" />
) : detailBalance ? (
<span style={{ color: '#1890ff' }}>
{detailBalance.position} USDC
</span>
) : (
<span style={{ color: '#999' }}>-</span>
)}
</Descriptions.Item>
</Descriptions>
<Divider />
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
title="API 凭证配置"
>
<Descriptions.Item label="API Key">
<Tag color={detailAccount.apiKeyConfigured ? 'success' : 'default'}>
{detailAccount.apiKeyConfigured ? '已配置' : '未配置'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="API Secret">
<Tag color={detailAccount.apiSecretConfigured ? 'success' : 'default'}>
{detailAccount.apiSecretConfigured ? '已配置' : '未配置'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="API Passphrase">
<Tag color={detailAccount.apiPassphraseConfigured ? 'success' : 'default'}>
{detailAccount.apiPassphraseConfigured ? '已配置' : '未配置'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="配置状态">
{detailAccount.apiKeyConfigured && detailAccount.apiSecretConfigured && detailAccount.apiPassphraseConfigured ? (
<Tag color="success"></Tag>
) : (
<Tag color="warning"></Tag>
)}
</Descriptions.Item>
</Descriptions>
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined) && (
<>
<Divider />
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
title="交易统计"
>
{detailAccount.totalOrders !== undefined && (
<Descriptions.Item label="总订单数">
{detailAccount.totalOrders}
</Descriptions.Item>
)}
{detailAccount.totalPnl !== undefined && (
<Descriptions.Item label="总盈亏">
<span style={{
fontWeight: 'bold',
color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
}}>
{detailAccount.totalPnl} USDC
</span>
</Descriptions.Item>
)}
</Descriptions>
</>
)}
</div>
) : (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin size="large" />
<div style={{ marginTop: '16px' }}>...</div>
</div>
)}
</Modal>
</div>
)
}
export default AccountList
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useState } from 'react'
import { Card, Form, Input, Button, Switch, Radio, InputNumber, message, Typography, Space } from 'antd'
import { SaveOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import type { CopyTradingConfig } from '../types'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
const ConfigPage: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [config, setConfig] = useState<CopyTradingConfig | null>(null)
useEffect(() => {
fetchConfig()
}, [])
const fetchConfig = async () => {
try {
const response = await apiService.config.get()
if (response.data.code === 0 && response.data.data) {
const data = response.data.data
setConfig(data)
form.setFieldsValue(data)
} else {
message.error(response.data.msg || '获取配置失败')
}
} catch (error: any) {
message.error(error.message || '获取配置失败')
}
}
const handleSubmit = async (values: any) => {
setLoading(true)
try {
const response = await apiService.config.update(values)
if (response.data.code === 0) {
message.success('更新配置成功')
fetchConfig()
} else {
message.error(response.data.msg || '更新配置失败')
}
} catch (error: any) {
message.error(error.message || '更新配置失败')
} finally {
setLoading(false)
}
}
return (
<div>
<div style={{ marginBottom: '16px' }}>
<Title level={2} style={{ margin: 0 }}></Title>
</div>
<Card>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
size={isMobile ? 'middle' : 'large'}
>
<Form.Item
label="跟单金额模式"
name="copyMode"
rules={[{ required: true, message: '请选择跟单金额模式' }]}
>
<Radio.Group>
<Radio value="RATIO"></Radio>
<Radio value="FIXED"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.copyMode !== currentValues.copyMode}
>
{({ getFieldValue }) => {
const copyMode = getFieldValue('copyMode')
return copyMode === 'RATIO' ? (
<Form.Item
label="跟单比例"
name="copyRatio"
rules={[{ required: true, message: '请输入跟单比例' }]}
help="跟单金额 = Leader 订单金额 × 跟单比例"
>
<InputNumber
min={0.1}
max={10}
step={0.1}
style={{ width: '100%' }}
placeholder="例如:1.0 表示 1:1 跟单"
/>
</Form.Item>
) : (
<Form.Item
label="固定跟单金额"
name="fixedAmount"
rules={[{ required: true, message: '请输入固定跟单金额' }]}
help="无论 Leader 订单大小如何,跟单金额都固定"
>
<InputNumber
min={0.01}
step={0.01}
style={{ width: '100%' }}
placeholder="USDC"
/>
</Form.Item>
)
}}
</Form.Item>
<Form.Item
label="单笔订单最大金额"
name="maxOrderSize"
rules={[{ required: true, message: '请输入最大金额' }]}
>
<InputNumber
min={0.01}
step={0.01}
style={{ width: '100%' }}
placeholder="USDC"
/>
</Form.Item>
<Form.Item
label="单笔订单最小金额"
name="minOrderSize"
rules={[{ required: true, message: '请输入最小金额' }]}
>
<InputNumber
min={0.01}
step={0.01}
style={{ width: '100%' }}
placeholder="USDC"
/>
</Form.Item>
<Form.Item
label="每日最大亏损限制"
name="maxDailyLoss"
rules={[{ required: true, message: '请输入最大亏损限制' }]}
>
<InputNumber
min={0}
step={0.01}
style={{ width: '100%' }}
placeholder="USDC"
/>
</Form.Item>
<Form.Item
label="每日最大跟单订单数"
name="maxDailyOrders"
rules={[{ required: true, message: '请输入最大订单数' }]}
>
<InputNumber
min={1}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
label="价格容忍度"
name="priceTolerance"
rules={[{ required: true, message: '请输入价格容忍度' }]}
help="百分比,允许价格在 Leader 价格 ± 容忍度范围内调整"
>
<InputNumber
min={0}
max={100}
step={0.1}
style={{ width: '100%' }}
addonAfter="%"
/>
</Form.Item>
<Form.Item
label="跟单延迟"
name="delaySeconds"
rules={[{ required: true, message: '请输入跟单延迟' }]}
help="延迟 N 秒后跟单(0 表示立即跟单)"
>
<InputNumber
min={0}
style={{ width: '100%' }}
addonAfter="秒"
/>
</Form.Item>
<Form.Item
label="轮询间隔"
name="pollIntervalSeconds"
rules={[{ required: true, message: '请输入轮询间隔' }]}
help="轮询 Leader 交易的间隔(仅在 WebSocket 不可用时使用)"
>
<InputNumber
min={1}
style={{ width: '100%' }}
addonAfter="秒"
/>
</Form.Item>
<Form.Item
label="优先使用 WebSocket 推送"
name="useWebSocket"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label="启用全局跟单"
name="enabled"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={loading}
size={isMobile ? 'middle' : 'large'}
>
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default ConfigPage
+146
View File
@@ -0,0 +1,146 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Input, Button, Select, Switch, message, Typography } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
const { Option } = Select
const LeaderAdd: React.FC = () => {
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
const handleSubmit = async (values: any) => {
setLoading(true)
try {
const response = await apiService.leaders.add({
leaderAddress: values.leaderAddress,
leaderName: values.leaderName,
accountId: values.accountId,
category: values.category,
enabled: values.enabled !== false
})
if (response.data.code === 0) {
message.success('添加 Leader 成功')
navigate('/leaders')
} else {
message.error(response.data.msg || '添加 Leader 失败')
}
} catch (error: any) {
message.error(error.message || '添加 Leader 失败')
} finally {
setLoading(false)
}
}
return (
<div>
<div style={{ marginBottom: '16px' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/leaders')}
style={{ marginBottom: '16px' }}
>
</Button>
<Title level={2} style={{ margin: 0 }}> Leader</Title>
</div>
<Card>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
size={isMobile ? 'middle' : 'large'}
initialValues={{
enabled: true
}}
>
<Form.Item
label="Leader 钱包地址"
name="leaderAddress"
rules={[
{ required: true, message: '请输入 Leader 钱包地址' },
{
pattern: /^0x[a-fA-F0-9]{40}$/,
message: '钱包地址格式不正确'
}
]}
>
<Input placeholder="0x..." />
</Form.Item>
<Form.Item
label="Leader 名称"
name="leaderName"
>
<Input placeholder="可选,用于标识 Leader" />
</Form.Item>
<Form.Item
label="使用的账户"
name="accountId"
help="选择用于跟单此 Leader 的账户,不选择则使用默认账户"
>
<Select placeholder="选择账户(可选)" allowClear>
{accounts.map(account => (
<Option key={account.id} value={account.id}>
{account.accountName || account.walletAddress} {account.isDefault && '(默认)'}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label="分类筛选"
name="category"
help="仅跟单该分类的交易,不选择则跟单所有分类"
>
<Select placeholder="选择分类(可选)" allowClear>
<Option value="sports">Sports</Option>
<Option value="crypto">Crypto</Option>
</Select>
</Form.Item>
<Form.Item
label="启用跟单"
name="enabled"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={loading}
size={isMobile ? 'middle' : 'large'}
>
Leader
</Button>
<Button onClick={() => navigate('/leaders')}>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default LeaderAdd
+155
View File
@@ -0,0 +1,155 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useState } from 'react'
import type { Leader } from '../types'
import { useMediaQuery } from 'react-responsive'
const LeaderList: React.FC = () => {
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [leaders, setLeaders] = useState<Leader[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
fetchLeaders()
}, [])
const fetchLeaders = async () => {
setLoading(true)
try {
const response = await apiService.leaders.list()
if (response.data.code === 0 && response.data.data) {
setLeaders(response.data.data.list || [])
} else {
message.error(response.data.msg || '获取 Leader 列表失败')
}
} catch (error: any) {
message.error(error.message || '获取 Leader 列表失败')
} finally {
setLoading(false)
}
}
const handleDelete = async (leaderId: number) => {
try {
const response = await apiService.leaders.delete({ leaderId })
if (response.data.code === 0) {
message.success('删除 Leader 成功')
fetchLeaders()
} else {
message.error(response.data.msg || '删除 Leader 失败')
}
} catch (error: any) {
message.error(error.message || '删除 Leader 失败')
}
}
const columns = [
{
title: 'Leader 名称',
dataIndex: 'leaderName',
key: 'leaderName',
render: (text: string, record: Leader) => text || `Leader ${record.id}`
},
{
title: '钱包地址',
dataIndex: 'leaderAddress',
key: 'leaderAddress',
render: (address: string) => (
<span style={{ fontFamily: 'monospace' }}>{address}</span>
)
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
render: (category: string | undefined) => category ? (
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
) : <Tag></Tag>
},
{
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
render: (enabled: boolean) => (
<Tag color={enabled ? 'success' : 'default'}>
{enabled ? '启用' : '禁用'}
</Tag>
)
},
{
title: '跟单比例',
dataIndex: 'copyRatio',
key: 'copyRatio',
render: (ratio: string) => `${ratio}x`
},
{
title: '操作',
key: 'action',
render: (_: any, record: Leader) => (
<Space size="small">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/leaders/edit?id=${record.id}`)}
>
</Button>
<Popconfirm
title="确定要删除这个 Leader 吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
)
}
]
return (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
flexWrap: 'wrap',
gap: '12px'
}}>
<h2>Leader </h2>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/leaders/add')}
size={isMobile ? 'middle' : 'large'}
>
Leader
</Button>
</div>
<Card>
<Table
dataSource={leaders}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
pageSize: isMobile ? 10 : 20,
showSizeChanger: !isMobile
}}
/>
</Card>
</div>
)
}
export default LeaderList
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react'
import { Card, Table, Tag, Space, message } from 'antd'
import { apiService } from '../services/api'
import type { CopyOrder } from '../types'
import { useMediaQuery } from 'react-responsive'
const OrderList: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const [orders, setOrders] = useState<CopyOrder[]>([])
const [loading, setLoading] = useState(false)
const [pagination, setPagination] = useState({
current: 1,
pageSize: 20,
total: 0
})
useEffect(() => {
fetchOrders()
}, [pagination.current, pagination.pageSize])
const fetchOrders = async () => {
setLoading(true)
try {
const response = await apiService.orders.list({
page: pagination.current,
limit: pagination.pageSize
})
if (response.data.code === 0 && response.data.data) {
setOrders(response.data.data.list || [])
setPagination(prev => ({
...prev,
total: response.data.data?.total || 0
}))
} else {
message.error(response.data.msg || '获取订单列表失败')
}
} catch (error: any) {
message.error(error.message || '获取订单列表失败')
} finally {
setLoading(false)
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'filled':
return 'success'
case 'cancelled':
return 'default'
case 'failed':
return 'error'
default:
return 'processing'
}
}
const getSideColor = (side: string) => {
return side === 'BUY' ? 'green' : 'red'
}
const columns = [
{
title: 'Leader',
dataIndex: 'leaderName',
key: 'leaderName',
render: (text: string, record: CopyOrder) => text || record.leaderAddress.slice(0, 10) + '...'
},
{
title: '市场',
dataIndex: 'marketId',
key: 'marketId',
render: (marketId: string) => (
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>
{marketId.slice(0, 10)}...
</span>
)
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
render: (category: string) => (
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
)
},
{
title: '方向',
dataIndex: 'side',
key: 'side',
render: (side: string) => (
<Tag color={getSideColor(side)}>{side}</Tag>
)
},
{
title: '价格',
dataIndex: 'price',
key: 'price'
},
{
title: '数量',
dataIndex: 'size',
key: 'size'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={getStatusColor(status)}>{status}</Tag>
)
},
{
title: '盈亏',
dataIndex: 'pnl',
key: 'pnl',
render: (pnl: string | undefined) => pnl ? (
<span style={{ color: pnl.startsWith('-') ? 'red' : 'green' }}>
{pnl} USDC
</span>
) : '-'
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (timestamp: number) => new Date(timestamp).toLocaleString()
}
]
return (
<div>
<div style={{ marginBottom: '16px' }}>
<h2></h2>
</div>
<Card>
<Table
dataSource={orders}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: !isMobile,
onChange: (page, pageSize) => {
setPagination(prev => ({ ...prev, current: page, pageSize }))
}
}}
scroll={isMobile ? { x: 800 } : undefined}
/>
</Card>
</div>
)
}
export default OrderList
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { Card, Row, Col, Statistic, message } from 'antd'
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import type { Statistics as StatisticsType } from '../types'
import { useMediaQuery } from 'react-responsive'
const Statistics: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const [stats, setStats] = useState<StatisticsType | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
fetchStatistics()
}, [])
const fetchStatistics = async () => {
setLoading(true)
try {
const response = await apiService.statistics.global()
if (response.data.code === 0 && response.data.data) {
setStats(response.data.data)
} else {
message.error(response.data.msg || '获取统计信息失败')
}
} catch (error: any) {
message.error(error.message || '获取统计信息失败')
} finally {
setLoading(false)
}
}
return (
<div>
<div style={{ marginBottom: '16px' }}>
<h2></h2>
</div>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="总订单数"
value={stats?.totalOrders || 0}
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="总盈亏"
value={stats?.totalPnl || '0'}
precision={2}
prefix={stats?.totalPnl && parseFloat(stats.totalPnl) >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
valueStyle={{ color: stats?.totalPnl && parseFloat(stats.totalPnl || '0') >= 0 ? '#3f8600' : '#cf1322' }}
suffix="USDC"
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="胜率"
value={stats?.winRate || '0'}
precision={2}
suffix="%"
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="平均盈亏"
value={stats?.avgPnl || '0'}
precision={2}
suffix="USDC"
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="最大盈利"
value={stats?.maxProfit || '0'}
precision={2}
prefix={<ArrowUpOutlined />}
valueStyle={{ color: '#3f8600' }}
suffix="USDC"
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="最大亏损"
value={stats?.maxLoss || '0'}
precision={2}
prefix={<ArrowDownOutlined />}
valueStyle={{ color: '#cf1322' }}
suffix="USDC"
loading={loading}
/>
</Card>
</Col>
</Row>
</div>
)
}
export default Statistics
+185
View File
@@ -0,0 +1,185 @@
import axios, { AxiosInstance } from 'axios'
import type { ApiResponse } from '../types'
/**
* API
*/
const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
/**
*
*/
apiClient.interceptors.request.use(
(config) => {
return config
},
(error) => {
return Promise.reject(error)
}
)
/**
*
*/
apiClient.interceptors.response.use(
(response) => {
return response
},
(error) => {
if (error.response) {
console.error('API 错误:', error.response.data)
} else if (error.request) {
console.error('网络错误:', error.request)
} else {
console.error('请求错误:', error.message)
}
return Promise.reject(error)
}
)
/**
* API
*/
export const apiService = {
/**
* API
*/
accounts: {
/**
*
*/
import: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/import', data),
/**
*
*/
update: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/update', data),
/**
*
*/
delete: (data: { accountId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/accounts/delete', data),
/**
*
*/
list: () =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/list', {}),
/**
*
*/
detail: (data: { accountId?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/detail', data),
/**
*
*/
balance: (data: { accountId?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/balance', data),
/**
*
*/
setDefault: (data: { accountId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/accounts/set-default', data)
},
/**
* Leader API
*/
leaders: {
/**
* Leader
*/
add: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/add', data),
/**
* Leader
*/
update: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/update', data),
/**
* Leader
*/
delete: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/leaders/delete', data),
/**
* Leader
*/
list: (data: { enabled?: boolean; category?: string } = {}) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/list', data)
},
/**
* API
*/
config: {
/**
*
*/
get: () =>
apiClient.post<ApiResponse<any>>('/copy-trading/config/get', {}),
/**
*
*/
update: (data: any) =>
apiClient.post<ApiResponse<void>>('/copy-trading/config/update', data)
},
/**
* API
*/
orders: {
/**
*
*/
list: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/orders/list', data),
/**
*
*/
cancel: (data: { copyOrderId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/orders/cancel', data)
},
/**
* API
*/
statistics: {
/**
*
*/
global: (data: { startTime?: number; endTime?: number } = {}) =>
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/global', data),
/**
* Leader
*/
leader: (data: { leaderId: number; startTime?: number; endTime?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/leader', data),
/**
*
*/
category: (data: { category: string; startTime?: number; endTime?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/category', data)
}
}
export default apiClient
+153
View File
@@ -0,0 +1,153 @@
import { create } from 'zustand'
import type { Account } from '../types'
import { apiService } from '../services/api'
interface AccountStore {
accounts: Account[]
currentAccount: Account | null
loading: boolean
error: string | null
// Actions
fetchAccounts: () => Promise<void>
setCurrentAccount: (account: Account | null) => void
importAccount: (data: any) => Promise<void>
updateAccount: (data: any) => Promise<void>
deleteAccount: (accountId: number) => Promise<void>
setDefaultAccount: (accountId: number) => Promise<void>
fetchAccountDetail: (accountId: number) => Promise<Account>
fetchAccountBalance: (accountId: number) => Promise<{
availableBalance: string
positionBalance: string
totalBalance: string
positions: any[]
}>
}
export const useAccountStore = create<AccountStore>((set, get) => ({
accounts: [],
currentAccount: null,
loading: false,
error: null,
fetchAccounts: async () => {
set({ loading: true, error: null })
try {
const response = await apiService.accounts.list()
if (response.data.code === 0 && response.data.data) {
const accounts = response.data.data.list || []
set({ accounts, loading: false })
// 设置默认账户为当前账户
const defaultAccount = accounts.find((a: Account) => a.isDefault)
if (defaultAccount) {
set({ currentAccount: defaultAccount })
}
} else {
set({ error: response.data.msg || '获取账户列表失败', loading: false })
}
} catch (error: any) {
set({ error: error.message || '获取账户列表失败', loading: false })
}
},
setCurrentAccount: (account) => {
set({ currentAccount: account })
},
importAccount: async (data) => {
set({ loading: true, error: null })
try {
const response = await apiService.accounts.import(data)
if (response.data.code === 0) {
await get().fetchAccounts()
} else {
set({ error: response.data.msg || '导入账户失败', loading: false })
throw new Error(response.data.msg || '导入账户失败')
}
} catch (error: any) {
set({ error: error.message || '导入账户失败', loading: false })
throw error
}
},
updateAccount: async (data) => {
set({ loading: true, error: null })
try {
const response = await apiService.accounts.update(data)
if (response.data.code === 0) {
await get().fetchAccounts()
} else {
set({ error: response.data.msg || '更新账户失败', loading: false })
throw new Error(response.data.msg || '更新账户失败')
}
} catch (error: any) {
set({ error: error.message || '更新账户失败', loading: false })
throw error
}
},
deleteAccount: async (accountId) => {
set({ loading: true, error: null })
try {
const response = await apiService.accounts.delete({ accountId })
if (response.data.code === 0) {
await get().fetchAccounts()
} else {
set({ error: response.data.msg || '删除账户失败', loading: false })
throw new Error(response.data.msg || '删除账户失败')
}
} catch (error: any) {
set({ error: error.message || '删除账户失败', loading: false })
throw error
}
},
setDefaultAccount: async (accountId) => {
set({ loading: true, error: null })
try {
const response = await apiService.accounts.setDefault({ accountId })
if (response.data.code === 0) {
await get().fetchAccounts()
} else {
set({ error: response.data.msg || '设置默认账户失败', loading: false })
throw new Error(response.data.msg || '设置默认账户失败')
}
} catch (error: any) {
set({ error: error.message || '设置默认账户失败', loading: false })
throw error
}
},
fetchAccountDetail: async (accountId) => {
set({ loading: true, error: null })
try {
const response = await apiService.accounts.detail({ accountId })
if (response.data.code === 0 && response.data.data) {
set({ loading: false })
return response.data.data
} else {
const errorMsg = response.data.msg || '获取账户详情失败'
set({ error: errorMsg, loading: false })
throw new Error(errorMsg)
}
} catch (error: any) {
set({ error: error.message || '获取账户详情失败', loading: false })
throw error
}
},
fetchAccountBalance: async (accountId) => {
try {
const response = await apiService.accounts.balance({ accountId })
if (response.data.code === 0 && response.data.data) {
return response.data.data
} else {
throw new Error(response.data.msg || '获取账户余额失败')
}
} catch (error: any) {
throw error
}
}
}))
+32
View File
@@ -0,0 +1,32 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
/* 移动端适配 */
@media (max-width: 768px) {
body {
font-size: 14px;
}
}
/* 桌面端适配 */
@media (min-width: 769px) {
body {
font-size: 16px;
}
}
+156
View File
@@ -0,0 +1,156 @@
/**
* API
*/
export interface ApiResponse<T> {
code: number
data: T | null
msg: string
}
/**
*
*/
export interface Account {
id: number
walletAddress: string
accountName?: string
isDefault: boolean
apiKeyConfigured: boolean
apiSecretConfigured: boolean
apiPassphraseConfigured: boolean
balance?: string
totalOrders?: number
totalPnl?: string
}
/**
*
*/
export interface AccountListResponse {
list: Account[]
total: number
}
/**
*
*/
export interface AccountImportRequest {
privateKey: string
walletAddress: string
accountName?: string
apiKey?: string
apiSecret?: string
apiPassphrase?: string
isDefault?: boolean
}
/**
*
*/
export interface AccountUpdateRequest {
accountId: number
accountName?: string
apiKey?: string
apiSecret?: string
apiPassphrase?: string
isDefault?: boolean
}
/**
* Leader
*/
export interface Leader {
id: number
leaderAddress: string
leaderName?: string
accountId?: number
category?: string
enabled: boolean
copyRatio: string
totalOrders?: number
totalPnl?: string
}
/**
* Leader
*/
export interface LeaderListResponse {
list: Leader[]
total: number
}
/**
* Leader
*/
export interface LeaderAddRequest {
leaderAddress: string
leaderName?: string
accountId?: number
category?: string
enabled?: boolean
}
/**
*
*/
export interface CopyTradingConfig {
copyMode: 'RATIO' | 'FIXED'
copyRatio: string
fixedAmount?: string
maxOrderSize: string
minOrderSize: string
maxDailyLoss: string
maxDailyOrders: number
priceTolerance: string
delaySeconds: number
pollIntervalSeconds: number
useWebSocket: boolean
websocketReconnectInterval: number
websocketMaxRetries: number
enabled: boolean
}
/**
*
*/
export interface CopyOrder {
id: number
accountId: number
leaderId: number
leaderAddress: string
leaderName?: string
marketId: string
category: string
side: 'BUY' | 'SELL'
price: string
size: string
copyRatio: string
orderId?: string
status: string
filledSize: string
pnl?: string
createdAt: number
}
/**
*
*/
export interface OrderListResponse {
list: CopyOrder[]
total: number
page: number
limit: number
}
/**
*
*/
export interface Statistics {
totalOrders: number
totalPnl: string
winRate: string
avgPnl: string
maxProfit: string
maxLoss: string
}
+150
View File
@@ -0,0 +1,150 @@
import { ethers } from 'ethers'
/**
*
*/
export function getAddressFromPrivateKey(privateKey: string): string {
try {
// 移除 0x 前缀(如果有)
const cleanKey = privateKey.startsWith('0x') ? privateKey.slice(2) : privateKey
// 创建钱包
const wallet = new ethers.Wallet(`0x${cleanKey}`)
return wallet.address
} catch (error) {
throw new Error(`无效的私钥: ${error}`)
}
}
/**
*
* @param mnemonic 1224
* @param index 0使
*/
export function getAddressFromMnemonic(mnemonic: string, index: number = 0): string {
try {
// 验证助记词格式
if (!isValidMnemonic(mnemonic)) {
throw new Error('助记词格式不正确')
}
// ethers.js v6: 如果 index 为 0,可以直接使用 Wallet.fromPhrase
// 它默认使用路径 m/44'/60'/0'/0/0
if (index === 0) {
const wallet = ethers.Wallet.fromPhrase(mnemonic.trim())
return wallet.address
}
// 对于其他索引,使用 HDNodeWallet
// 从助记词创建 Mnemonic 对象
const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim())
// 使用标准 BIP44 路径直接创建钱包:m/44'/60'/0'/0/index
// ethers.js v6: HDNodeWallet.fromMnemonic 可以直接指定路径
const derivationPath = `m/44'/60'/0'/0/${index}`
const wallet = ethers.HDNodeWallet.fromMnemonic(mnemonicObj, derivationPath)
return wallet.address
} catch (error: any) {
// 如果直接指定路径失败,尝试分步派生
try {
const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim())
// 先创建根节点(不指定路径)
const rootNode = ethers.HDNodeWallet.fromMnemonic(mnemonicObj)
// 使用相对路径(不以 m/ 开头)
const relativePath = `44'/60'/0'/0/${index}`
const wallet = rootNode.derivePath(relativePath)
return wallet.address
} catch (fallbackError: any) {
throw new Error(`无效的助记词: ${error.message || error}`)
}
}
}
/**
*
* @param mnemonic 1224
* @param index 0使
*/
export function getPrivateKeyFromMnemonic(mnemonic: string, index: number = 0): string {
try {
// 验证助记词格式
if (!isValidMnemonic(mnemonic)) {
throw new Error('助记词格式不正确')
}
// ethers.js v6: 如果 index 为 0,可以直接使用 Wallet.fromPhrase
// 它默认使用路径 m/44'/60'/0'/0/0
if (index === 0) {
const wallet = ethers.Wallet.fromPhrase(mnemonic.trim())
return wallet.privateKey
}
// 对于其他索引,使用 HDNodeWallet
// 从助记词创建 Mnemonic 对象
const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim())
// 使用标准 BIP44 路径直接创建钱包:m/44'/60'/0'/0/index
// ethers.js v6: HDNodeWallet.fromMnemonic 可以直接指定路径
const derivationPath = `m/44'/60'/0'/0/${index}`
const wallet = ethers.HDNodeWallet.fromMnemonic(mnemonicObj, derivationPath)
return wallet.privateKey
} catch (error: any) {
// 如果直接指定路径失败,尝试分步派生
try {
const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim())
// 先创建根节点(不指定路径)
const rootNode = ethers.HDNodeWallet.fromMnemonic(mnemonicObj)
// 使用相对路径(不以 m/ 开头)
const relativePath = `44'/60'/0'/0/${index}`
const wallet = rootNode.derivePath(relativePath)
return wallet.privateKey
} catch (fallbackError: any) {
throw new Error(`无法从助记词导出私钥: ${error.message || error}`)
}
}
}
/**
*
*/
export function isValidMnemonic(mnemonic: string): boolean {
try {
if (!mnemonic || !mnemonic.trim()) {
return false
}
const words = mnemonic.trim().split(/\s+/)
// 助记词应该是 12 或 24 个单词
if (words.length !== 12 && words.length !== 24) {
return false
}
// 使用 ethers 验证助记词
ethers.Mnemonic.fromPhrase(mnemonic.trim())
return true
} catch {
return false
}
}
/**
*
*/
export function isValidWalletAddress(address: string): boolean {
return /^0x[a-fA-F0-9]{40}$/.test(address)
}
/**
*
*/
export function isValidPrivateKey(privateKey: string): boolean {
try {
const cleanKey = privateKey.startsWith('0x') ? privateKey.slice(2) : privateKey
return /^[a-fA-F0-9]{64}$/.test(cleanKey)
} catch {
return false
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
})