diff --git a/.env b/.env index 23674f9..2902ede 100644 --- a/.env +++ b/.env @@ -1,6 +1,6 @@ # API 基础地址,不设置时默认 https://api.xtrader.vip # 连接测试服务器 192.168.3.21:8888 时复制本文件为 .env 或 .env.local 并取消下一行注释: -# VITE_API_BASE_URL=http://192.168.3.21:8888 +# VITE_API_BASE_URL=http://10.117.63.212:8888 # SSH 部署(npm run deploy),可选覆盖 # DEPLOY_HOST=38.246.250.238 diff --git a/docs/api/request.md b/docs/api/request.md index e0b2f14..9b0c7f3 100644 --- a/docs/api/request.md +++ b/docs/api/request.md @@ -9,6 +9,7 @@ HTTP 请求基础封装,提供 `get` 和 `post` 方法,支持自定义请求 ## 核心能力 - 统一 BASE_URL:默认 `https://api.xtrader.vip`,可通过环境变量 `VITE_API_BASE_URL` 覆盖 +- CLOB WebSocket URL:`getClobWsUrl()` 返回与 REST API 同源的 `ws(s)://host/api/clob/ws` - GET 请求:支持 query 参数,自动序列化 - POST 请求:支持 JSON body - 自定义 headers:通过 `RequestConfig.headers` 传入 @@ -16,7 +17,10 @@ HTTP 请求基础封装,提供 `get` 和 `post` 方法,支持自定义请求 ## 使用方式 ```typescript -import { get, post } from '@/api/request' +import { get, post, getClobWsUrl } from '@/api/request' + +// CLOB WebSocket URL(与 REST 同源) +const wsUrl = getClobWsUrl() // e.g. wss://api.xtrader.vip/api/clob/ws // GET 请求 const data = await get('/path', { page: 1, keyword: 'x' }) diff --git a/docs/components/OrderBook.md b/docs/components/OrderBook.md index bb7e124..4f2c713 100644 --- a/docs/components/OrderBook.md +++ b/docs/components/OrderBook.md @@ -4,24 +4,44 @@ ## 功能用途 -订单簿组件,展示 Asks(卖单)与 Bids(买单)列表,含价格、份额、累计总量,以及横向进度条表示深度。当前使用 mock 数据。 +订单簿组件,展示 Asks(卖单)与 Bids(买单)列表,含价格、份额、累计总量,以及横向进度条表示深度。支持通过 props 接收 CLOB WebSocket 实时数据,无 props 时回退到 mock 数据。 ## 核心能力 - Trade Up / Trade Down Tab - Asks、Bids 列表,带 `HorizontalProgressBar` 深度条 - Last price、Spread 展示 +- Live / 连接中 状态展示 + +## Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| asks | `{ price: number; shares: number }[]` | `[]` | 卖单列表(价格单位:¢) | +| bids | `{ price: number; shares: number }[]` | `[]` | 买单列表 | +| lastPrice | `number` | - | 最新成交价(¢) | +| spread | `number` | - | 买卖价差(¢) | +| loading | `boolean` | `false` | 是否连接中 | +| connected | `boolean` | `false` | 是否已连接 WebSocket | ## 使用方式 ```vue + + + + ``` -当前为展示组件,无 props。后续可扩展为接收 `marketId` 等,从接口或 WebSocket 获取真实数据。 - ## 扩展方式 -1. **真实数据**:接收 `asks`、`bids` props,或内部根据路由/context 拉取 -2. **WebSocket**:订阅订单簿推送,实时更新 -3. **点击下单**:点击某行价格时,将价格传入 TradeComponent +1. **点击下单**:点击某行价格时,将价格传入 TradeComponent +2. **Trade Up/Down**:可按 Yes/No Token 切换展示不同订单簿 diff --git a/docs/views/TradeDetail.md b/docs/views/TradeDetail.md index 4391235..8237d28 100644 --- a/docs/views/TradeDetail.md +++ b/docs/views/TradeDetail.md @@ -10,7 +10,7 @@ ## 核心能力 - 分时图:ECharts 渲染,支持 Past、时间粒度切换 -- 订单簿:`OrderBook` 组件 +- 订单簿:`OrderBook` 组件,通过 **ClobSdk** 对接 CLOB WebSocket 实时数据(全量快照、增量更新、成交推送) - 交易:`TradeComponent`,传入 `market`、`initialOption` - 移动端:底部栏 + `v-bottom-sheet` 嵌入 `TradeComponent` - Merge/Split:通过 `TradeComponent` 或底部菜单触发 @@ -22,7 +22,8 @@ ## 扩展方式 -1. **实时数据**:订单簿、分时图接入 WebSocket -2. **Comments**:对接评论接口,替换 placeholder -3. **Top Holders**:对接持仓接口 -4. **Activity**:对接交易活动接口,替换 mock 数据 +1. **订单簿**:已通过 `sdk/clobSocket.ts` 的 ClobSdk 对接 CLOB WebSocket,使用 **Yes/No token ID** 订阅 `price_size_all`、`price_size_delta`、`trade` 消息 +2. **分时图**:可接入 WebSocket 推送的图表数据 +3. **Comments**:对接评论接口,替换 placeholder +4. **Top Holders**:对接持仓接口 +5. **Activity**:已对接 CLOB `trade` 消息,实时追加成交记录 diff --git a/package-lock.json b/package-lock.json index b90f399..758254d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "siwe": "^3.0.0", "vue": "^3.5.27", "vue-router": "^5.0.1", - "vuetify": "^4.0.0-beta.0" + "vuetify": "^4.0.0-beta.0", + "ws": "^8.19.0" }, "devDependencies": { "@playwright/test": "^1.58.1", @@ -9085,7 +9086,6 @@ "version": "8.19.0", "resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 4722a00..bb251ac 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "siwe": "^3.0.0", "vue": "^3.5.27", "vue-router": "^5.0.1", - "vuetify": "^4.0.0-beta.0" + "vuetify": "^4.0.0-beta.0", + "ws": "^8.19.0" }, "devDependencies": { "@playwright/test": "^1.58.1", diff --git a/sdk/.gitkeep b/sdk/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sdk/clobSocket.ts b/sdk/clobSocket.ts new file mode 100644 index 0000000..bba438a --- /dev/null +++ b/sdk/clobSocket.ts @@ -0,0 +1,296 @@ + +// 前端 CLOB WebSocket SDK +// 该 SDK 提供了连接到 CLOB WebSocket 服务的 TypeScript 接口。 +// 它支持针对不同消息类型的特定回调: +// - Welcome (连接建立) +// - PriceSizeAll (全量订单簿快照) +// - PriceSizeDelta (增量订单簿更新) +// - Trade (成交记录) + +export interface ClobSocketOptions { + // WebSocket URL (例如: "ws://localhost:8888/clob/ws" 或 "/api/clob/ws") + url?: string; + // 断开连接时是否自动重连 + autoReconnect?: boolean; + // 重连间隔时间(毫秒) + reconnectInterval?: number; + // 最大重连尝试次数 + maxReconnectAttempts?: number; +} + +// 基于 Go 结构体的消息接口定义 +export interface WelcomeMsg { + type: 'welcome'; + t: number; +} + +export interface PriceSizePolyMsg { + i: number; // 索引 + e: 'price_size_all' | 'price_size_delta'; // 事件类型 + m: string; // 市场 ID + t: number; // 时间戳 + b?: Record; // 买单: 价格 -> 数量 + s?: Record; // 卖单: 价格 -> 数量 +} + +export interface TradePolyMsg { + e: 'trade'; // 事件类型 + m: string; // 市场 ID + p: string; // 价格 + s: string; // 数量 + side: string; // "buy" 或 "sell" +} + +export type ClobMsg = WelcomeMsg | PriceSizePolyMsg | TradePolyMsg; + +// 回调函数类型定义 +export type WelcomeCallback = (data: WelcomeMsg) => void; +export type PriceSizeAllCallback = (data: PriceSizePolyMsg) => void; +export type PriceSizeDeltaCallback = (data: PriceSizePolyMsg) => void; +export type TradeCallback = (data: TradePolyMsg) => void; +export type ConnectCallback = (event: Event) => void; +export type DisconnectCallback = (event: CloseEvent) => void; +export type ErrorCallback = (event: Event) => void; + +export class ClobSdk { + private url: string; + private tokenIds: string[]; + private ws: WebSocket | null = null; + private autoReconnect: boolean; + private reconnectInterval: number; + private maxReconnectAttempts: number; + private reconnectAttempts = 0; + private isExplicitClose = false; + + private listeners: { + welcome: WelcomeCallback[]; + priceSizeAll: PriceSizeAllCallback[]; + priceSizeDelta: PriceSizeDeltaCallback[]; + trade: TradeCallback[]; + connect: ConnectCallback[]; + disconnect: DisconnectCallback[]; + error: ErrorCallback[]; + } = { + welcome: [], + priceSizeAll: [], + priceSizeDelta: [], + trade: [], + connect: [], + disconnect: [], + error: [] + }; + + /** + * 初始化 CLOB SDK + * @param tokenIds Yes/No 的 token ID,可传单个或数组,如 [yesTokenId, noTokenId] + * @param options 配置选项 + */ + constructor(tokenIds: string | string[], options: ClobSocketOptions = {}) { + this.tokenIds = Array.isArray(tokenIds) ? tokenIds : [tokenIds]; + + // 如果未提供 URL,默认为相对路径 + // 修改为 Node.js 测试环境适配:如果 window 未定义,回退到 localhost + let protocol = 'ws:'; + let host = '10.117.63.212:8888'; + + if (typeof window !== 'undefined' && window.location) { + protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + host = window.location.host; + } + + this.url = options.url || `${protocol}//${host}/clob/ws`; + + this.autoReconnect = options.autoReconnect ?? true; + this.reconnectInterval = options.reconnectInterval || 3000; + this.maxReconnectAttempts = options.maxReconnectAttempts || 10; + + console.log('[ClobSdk] 构造完成,最终参数:', { + tokenIds: this.tokenIds, + url: this.url, + autoReconnect: this.autoReconnect, + reconnectInterval: this.reconnectInterval, + maxReconnectAttempts: this.maxReconnectAttempts, + }); + } + + /** + * 连接到 WebSocket 服务 + */ + public connect(): void { + this.isExplicitClose = false; + + try { + this.ws = new WebSocket(this.url); + + this.ws.onopen = (event: any) => { + console.log(`[ClobSdk] 已连接到 ${this.url}`); + this.reconnectAttempts = 0; + this.notifyConnect(event); + this.subscribe(); + }; + + this.ws.onmessage = (event: any) => { + try { + const rawData = JSON.parse(event.data); + // console.log('[ClobSdk] 收到消息:', rawData); + this.handleMessage(rawData); + } catch (e) { + console.error('[ClobSdk] 消息解析失败:', e); + } + }; + + this.ws.onclose = (event: any) => { + console.log('[ClobSdk] 连接关闭', { code: event.code, reason: event.reason, explicit: this.isExplicitClose }); + if (!this.isExplicitClose) { + this.notifyDisconnect(event); + if (this.autoReconnect) { + this.attemptReconnect(); + } + } else { + this.notifyDisconnect(event); + } + }; + + this.ws.onerror = (event: any) => { + console.error('[ClobSdk] 错误:', event); + this.notifyError(event); + }; + + } catch (e) { + console.error('[ClobSdk] 连接错误:', e); + if (this.autoReconnect) { + this.attemptReconnect(); + } + } + } + + /** + * 断开 WebSocket 服务连接 + */ + public disconnect(): void { + this.isExplicitClose = true; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + // --- 事件监听器 --- + + public onWelcome(callback: WelcomeCallback): () => void { + this.listeners.welcome.push(callback); + return () => this.removeListener('welcome', callback); + } + + public onPriceSizeAll(callback: PriceSizeAllCallback): () => void { + this.listeners.priceSizeAll.push(callback); + return () => this.removeListener('priceSizeAll', callback); + } + + public onPriceSizeDelta(callback: PriceSizeDeltaCallback): () => void { + this.listeners.priceSizeDelta.push(callback); + return () => this.removeListener('priceSizeDelta', callback); + } + + public onTrade(callback: TradeCallback): () => void { + this.listeners.trade.push(callback); + return () => this.removeListener('trade', callback); + } + + public onConnect(callback: ConnectCallback): () => void { + this.listeners.connect.push(callback); + return () => this.removeListener('connect', callback); + } + + public onDisconnect(callback: DisconnectCallback): () => void { + this.listeners.disconnect.push(callback); + return () => this.removeListener('disconnect', callback); + } + + public onError(callback: ErrorCallback): () => void { + this.listeners.error.push(callback); + return () => this.removeListener('error', callback); + } + + private removeListener(type: keyof typeof this.listeners, callback: any): void { + this.listeners[type] = (this.listeners[type] as any[]).filter((cb: any) => cb !== callback); + } + + // --- 内部方法 --- + + private subscribe(): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + const msg = { + type: 'market', + assets_ids: this.tokenIds + }; + this.ws.send(JSON.stringify(msg)); + console.log('[ClobSdk] 已订阅 tokenIds:', this.tokenIds); + } + } + + private handleMessage(data: any): void { + // Polymarket 消息有时是数组,有时是对象 (welcome) + if (Array.isArray(data)) { + data.forEach(msg => this.dispatchMessage(msg)); + } else { + this.dispatchMessage(data); + } + } + + private dispatchMessage(msg: any): void { + // 1. Welcome 消息 + if (msg.type === 'welcome') { + console.log('[ClobSdk] 回调: welcome', msg); + this.listeners.welcome.forEach(cb => cb(msg as WelcomeMsg)); + return; + } + + // 2. 基于 'e' 字段的事件 + switch (msg.e) { + case 'price_size_all': + console.log('[ClobSdk] 回调: price_size_all', { m: msg.m, t: msg.t, bids: msg.b, asks: msg.s }); + this.listeners.priceSizeAll.forEach(cb => cb(msg as PriceSizePolyMsg)); + break; + case 'price_size_delta': + console.log('[ClobSdk] 回调: price_size_delta', { m: msg.m, t: msg.t, bids: msg.b, asks: msg.s }); + this.listeners.priceSizeDelta.forEach(cb => cb(msg as PriceSizePolyMsg)); + break; + case 'trade': + // console.log('[ClobSdk] 回调: trade', { m: msg.m, p: msg.p, s: msg.s, side: msg.side }); + this.listeners.trade.forEach(cb => cb(msg as TradePolyMsg)); + break; + default: + console.warn('[ClobSdk] 未知消息类型:', msg); + } + } + + private notifyConnect(event: Event): void { + console.log('[ClobSdk] 回调: onConnect'); + this.listeners.connect.forEach(cb => cb(event)); + } + + private notifyDisconnect(event: CloseEvent): void { + console.log('[ClobSdk] 回调: onDisconnect', { code: event.code, reason: event.reason }); + this.listeners.disconnect.forEach(cb => cb(event)); + } + + private notifyError(event: Event): void { + console.log('[ClobSdk] 回调: onError', event); + this.listeners.error.forEach(cb => cb(event)); + } + + private attemptReconnect(): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('[ClobSdk] 达到最大重连次数。停止重连。'); + return; + } + + this.reconnectAttempts++; + console.log(`[ClobSdk] ${this.reconnectInterval}ms 后尝试重连 (尝试次数 ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`); + + setTimeout(() => { + this.connect(); + }, this.reconnectInterval); + } +} diff --git a/sdk/index.ts b/sdk/index.ts new file mode 100644 index 0000000..db8ebf3 --- /dev/null +++ b/sdk/index.ts @@ -0,0 +1,91 @@ + +import { ClobSdk, PriceSizePolyMsg, TradePolyMsg, WelcomeMsg } from './clobSocket'; +import WebSocket from 'ws'; + +// 模拟浏览器环境 +// 因为 ClobSdk 主要是为前端设计的,这里需要在 Node.js 环境中注入 WebSocket 全局对象 +(global as any).WebSocket = WebSocket; +(global as any).window = undefined; // 确保在构造函数中命中回退路径 (fallback path) + +// 配置 +// 从环境变量中获取配置,TOKEN_IDS 为逗号分隔的 Yes/No token ID +const TOKEN_IDS = process.env.TOKEN_IDS + ? process.env.TOKEN_IDS.split(',').map((s) => s.trim()).filter(Boolean) + : ['token_yes', 'token_no'] +const WS_URL = process.env.WS_URL || "ws://localhost:8888/clob/ws"; + +console.log(`正在启动 CLOB WebSocket 测试客户端...`); +console.log(`目标 URL: ${WS_URL}`); +console.log(`Token IDs: ${TOKEN_IDS.join(', ')}`); + +// 初始化 SDK +const sdk = new ClobSdk(TOKEN_IDS, { + url: WS_URL, + autoReconnect: true, // 启用自动重连 + reconnectInterval: 2000 // 重连间隔 2 秒 +}); + +// 注册回调函数 + +// 1. 连接建立事件 +sdk.onConnect(() => { + console.log('✅ 已连接到 WebSocket 服务器'); +}); + +// 2. Welcome 消息 (连接成功后服务器发送的第一条消息) +sdk.onWelcome((msg: WelcomeMsg) => { + console.log('👋 收到 Welcome 消息:', msg); +}); + +// 3. 全量订单簿快照 (PriceSizeAll) +// 当订阅成功时,通常会收到一次全量快照 +sdk.onPriceSizeAll((msg: PriceSizePolyMsg) => { + console.log('\n📚 收到全量订单簿快照:'); + console.log(` 市场: ${msg.m}`); + console.log(` 时间戳: ${msg.t}`); + console.log(` 买单 (Buys): ${JSON.stringify(msg.b)}`); + console.log(` 卖单 (Sells): ${JSON.stringify(msg.s)}`); +}); + +// 4. 增量更新 (PriceSizeDelta) +// 当订单簿发生变化时,会收到增量更新 +sdk.onPriceSizeDelta((msg: PriceSizePolyMsg) => { + console.log('\n🔄 收到订单簿增量更新:'); + console.log(` 市场: ${msg.m}`); + console.log(` 时间戳: ${msg.t}`); + if (msg.b) console.log(` 买单更新: ${JSON.stringify(msg.b)}`); + if (msg.s) console.log(` 卖单更新: ${JSON.stringify(msg.s)}`); +}); + +// 5. 成交记录 (Trade Execution) +// 当发生撮合交易时触发 +sdk.onTrade((msg: TradePolyMsg) => { + console.log('\n💰 交易成交:'); + console.log(` 市场: ${msg.m}`); + console.log(` 价格: ${msg.p}`); + console.log(` 数量: ${msg.s}`); + console.log(` 方向: ${msg.side}`); +}); + +// 6. 错误处理 +sdk.onError((err: Event) => { + console.error('❌ WebSocket 错误:', err); +}); + +// 7. 断开连接 +sdk.onDisconnect((event: CloseEvent) => { + console.log(`⚠️ 连接断开: 代码 ${event.code}`); +}); + +// 开始连接 +sdk.connect(); + +// 保持进程运行 (防止 Node.js 脚本立即退出) +process.stdin.resume(); + +// 处理退出信号 (Ctrl+C) +process.on('SIGINT', () => { + console.log('\n正在断开连接...'); + sdk.disconnect(); + process.exit(); +}); diff --git a/sdk/loadTest.ts b/sdk/loadTest.ts new file mode 100644 index 0000000..7f6723e --- /dev/null +++ b/sdk/loadTest.ts @@ -0,0 +1,320 @@ + +import axios from 'axios'; +import WebSocket from 'ws'; + +// 配置 +const CONFIG = { + baseURL: 'http://localhost:8888', // API 基础地址 + wsURL: 'ws://localhost:8888/clob/ws', // WebSocket 地址 + marketId: 'market_123', // 默认市场 ID (如果没有从 API 获取到) + tokenId: 'market_123', // 默认 Token ID (如果没有从 API 获取到) + userCount: 20, // 并发用户数量 + ordersPerUser: 100, // 每个用户的下单总数 + orderIntervalMs: 1000, // 下单间隔 (毫秒) + basePrice: 5000, // 基准价格 (例如: 0.50,如果精度是 10000) + priceSpread: 10, // 价格波动范围 (+/- 10 个步长,每个步长 100) + baseSize: 1000000, // 基准数量 (1 个单位) + sizeSpread: 5 // 数量波动范围 (+/- 5 个单位) +}; + +// 市场响应接口定义 +interface PmMarket { + ID: number; + clobTokenIds: string[]; // 数据库中可能是 JSON 字符串,但 API 可能返回解析后的对象或数组 +} + +// 用户会话状态接口 +interface UserSession { + index: number; // 用户索引 (1-based) + walletAddress: string; // 钱包地址 + token: string; // 登录 Token + userId: number; // 用户 ID + ordersPlaced: number; // 已下单数量 + successCount: number; // 下单成功数量 + failCount: number; // 下单失败数量 + wsMessages: number; // 接收到的 WebSocket 消息数量 + totalLatency: number; // 总延迟 (毫秒,用于计算平均值) + intervalId?: NodeJS.Timeout;// 定时器 ID + ws?: WebSocket; // WebSocket 连接实例 +} + +// 生成确定性的测试钱包地址 +// 格式: 0x0000... + 索引 (补全到 40 个字符) +// 这种格式用于配合后端的测试模式,绕过签名验证 +function getTestWalletAddress(index: number): string { + const hexIndex = index.toString(16).padStart(36, '0'); + return `0x0000${hexIndex}`; +} + +// 用户登录 +// 使用测试钱包地址登录,获取 Token +async function loginUser(index: number): Promise { + const walletAddress = getTestWalletAddress(index + 1); // 从 1 开始编号 + + try { + // 准备登录负载 + // 服务器逻辑: 如果是测试环境且钱包地址以 "0x0000" 开头,则跳过签名验证 + const payload = { + walletAddress: walletAddress, + nonce: "test_nonce", + signature: "test_signature", + message: "test_message" + }; + + const response = await axios.post(`${CONFIG.baseURL}/base/walletLogin`, payload); + + if (response.data.code === 0 && response.data.data) { + const userData = response.data.data.user; + const token = response.data.data.token; + // console.log(`[用户 ${index + 1}] 登录成功。ID: ${userData.ID}`); + + return { + index: index + 1, + walletAddress, + token, + userId: userData.ID, + ordersPlaced: 0, + successCount: 0, + failCount: 0, + wsMessages: 0, + totalLatency: 0 + }; + } else { + console.error(`[用户 ${index + 1}] 登录失败:`, response.data.msg); + return null; + } + } catch (error: any) { + console.error(`[用户 ${index + 1}] 登录错误:`, error.message); + return null; + } +} + +// 建立 WebSocket 连接并订阅市场 +function connectWs(session: UserSession, tokenId: string) { + // 当前系统 WebSocket 连接不需要鉴权 (基于 public group) + + const ws = new WebSocket(CONFIG.wsURL); + + ws.on('open', () => { + // 连接打开后发送订阅消息 + const msg = { + type: "market", + assets_ids: [tokenId] + }; + ws.send(JSON.stringify(msg)); + }); + + ws.on('message', (data: WebSocket.Data) => { + session.wsMessages++; + // const msg = JSON.parse(data.toString()); + // console.log(`[用户 ${session.index}] WS 消息:`, msg); + }); + + ws.on('error', (err) => { + // console.error(`[用户 ${session.index}] WS 错误:`, err.message); + }); + + session.ws = ws; +} + +// 执行下单操作 +async function placeOrder(session: UserSession) { + // 检查是否达到下单上限 + if (session.ordersPlaced >= CONFIG.ordersPerUser) { + if (session.intervalId) { + clearInterval(session.intervalId); + } + if (session.ws) { + session.ws.close(); + } + return; + } + + // 方向: 随机买入/卖出 (1=买, 2=卖) + const isBuy = Math.random() > 0.5; + const side = isBuy ? 1 : 2; + + // 价格: 基准价格 +/- 随机波动 (步长 100) + // 5000 +/- (0..10)*100 + const priceOffset = Math.floor(Math.random() * CONFIG.priceSpread) * 100; + const price = isBuy ? CONFIG.basePrice - priceOffset : CONFIG.basePrice + priceOffset; + + // 数量: 基准数量 +/- 随机波动 (步长 1000000) + const sizeOffset = Math.floor(Math.random() * CONFIG.sizeSpread) * 1000000; + const size = CONFIG.baseSize + sizeOffset; + + const payload = { + tokenID: CONFIG.tokenId, + side: side, + price: price, + size: size, + feeRateBps: 10, + nonce: Date.now(), // 简单的 nonce + expiration: 0, // 0 表示不过期 (GTC) + orderType: 0 // 0: GTC (Good Till Cancel) + }; + + const start = Date.now(); + try { + const response = await axios.post(`${CONFIG.baseURL}/clob/gateway/submitOrder`, payload, { + headers: { + 'x-token': session.token + } + }); + + const latency = Date.now() - start; + session.totalLatency += latency; + + session.ordersPlaced++; + + if (response.data.code === 0) { + session.successCount++; + } else { + session.failCount++; + // 仅打印前 5 个错误,避免日志刷屏 + if (session.failCount <= 5) { + console.error(`[用户 ${session.index}] 下单失败:`, response.data.msg); + } + } + + } catch (error: any) { + session.ordersPlaced++; + session.failCount++; + // 仅打印前 5 个错误 + if (session.failCount <= 5) { + let msg = error.message; + if (error.response) { + msg += ` 状态码: ${error.response.status}`; + } + console.error(`[用户 ${session.index}] 下单错误:`, msg); + } + } +} + +// 主函数:运行负载测试 +async function runLoadTest() { + console.log(`开始负载测试,并发用户数: ${CONFIG.userCount}...`); + console.log(`目标: 每用户 ${CONFIG.ordersPerUser} 单`); + console.log(`速率: 每 ${CONFIG.orderIntervalMs}ms 一单`); + + // 1. 所有用户登录 + const sessions: UserSession[] = []; + const loginPromises = []; + + for (let i = 0; i < CONFIG.userCount; i++) { + loginPromises.push(loginUser(i).then(session => { + if (session) sessions.push(session); + })); + // 稍微错开登录时间,避免瞬间压垮数据库连接池 + await new Promise(r => setTimeout(r, 50)); + } + + await Promise.all(loginPromises); + + console.log(`\n成功登录 ${sessions.length}/${CONFIG.userCount} 个用户。`); + + // 2. 获取市场列表以找到有效的 Token ID + if (sessions.length > 0) { + try { + console.log("正在获取市场列表以查找有效 Token ID..."); + const marketRes = await axios.get(`${CONFIG.baseURL}/PmMarket/getPmMarketList?page=1&pageSize=1`, { + headers: { + 'x-token': sessions[0].token + } + }); + + if (marketRes.data.code === 0 && marketRes.data.data.list && marketRes.data.data.list.length > 0) { + const market = marketRes.data.data.list[0]; + console.log(`找到市场: ${market.ID} - ${market.question}`); + + // 解析 clobTokenIds + let tokenIds = market.clobTokenIds; + if (typeof tokenIds === 'string') { + try { + tokenIds = JSON.parse(tokenIds); + } catch (e) { + // console.error("解析 clobTokenIds JSON 失败:", e); + } + } + + if (Array.isArray(tokenIds) && tokenIds.length > 0) { + CONFIG.tokenId = tokenIds[0]; + CONFIG.marketId = market.ID.toString(); + console.log(`使用配置 -> MarketID: ${CONFIG.marketId}, TokenID: ${CONFIG.tokenId}`); + } else { + console.error("市场没有有效的 clobTokenIds 数组:", tokenIds); + } + } else { + console.error("未找到市场或 API 错误:", marketRes.data); + } + } catch (error: any) { + console.error("获取市场列表失败:", error.message); + } + } + + // 3. 所有用户建立 WebSocket 连接 + console.log("正在连接 WebSocket..."); + sessions.forEach(s => connectWs(s, CONFIG.tokenId)); + + console.log('开始下单循环...\n'); + + const startTime = Date.now(); + + // 4. 启动下单循环 + // 使用 Promise 跟踪每个用户的任务完成情况 + const userTasks = sessions.map(session => { + return new Promise((resolve) => { + // 初始随机延迟,错开启动时间 + setTimeout(() => { + session.intervalId = setInterval(async () => { + if (session.ordersPlaced >= CONFIG.ordersPerUser) { + if (session.intervalId) clearInterval(session.intervalId); + if (session.ws) session.ws.close(); + resolve(); + return; + } + await placeOrder(session); + }, CONFIG.orderIntervalMs); + }, Math.random() * 1000); // 1秒内的随机延迟 + }); + }); + + // 5. 状态监控 (每秒刷新) + const monitorInterval = setInterval(() => { + const totalPlaced = sessions.reduce((sum, s) => sum + s.ordersPlaced, 0); + const totalSuccess = sessions.reduce((sum, s) => sum + s.successCount, 0); + const totalFail = sessions.reduce((sum, s) => sum + s.failCount, 0); + const totalWs = sessions.reduce((sum, s) => sum + s.wsMessages, 0); + const totalLatency = sessions.reduce((sum, s) => sum + s.totalLatency, 0); + const avgLatency = totalSuccess > 0 ? (totalLatency / totalSuccess).toFixed(0) : "0"; + + const elapsed = (Date.now() - startTime) / 1000; + const rate = elapsed > 0 ? (totalPlaced / elapsed).toFixed(1) : "0.0"; + + // 覆盖当前行输出状态 + process.stdout.write(`\r[状态] 时间: ${elapsed.toFixed(1)}s | 订单: ${totalPlaced}/${CONFIG.userCount * CONFIG.ordersPerUser} | 速率: ${rate} ops | 成功: ${totalSuccess} | 失败: ${totalFail} | WS消息: ${totalWs} | 延迟: ${avgLatency}ms`); + + if (totalPlaced >= CONFIG.userCount * CONFIG.ordersPerUser) { + clearInterval(monitorInterval); + process.stdout.write('\n'); // 完成后换行 + } + }, 1000); + + // 等待所有用户完成 + await Promise.all(userTasks); + + const endTime = Date.now(); + const duration = (endTime - startTime) / 1000; + + console.log('\n\n负载测试完成!'); + console.log(`总耗时: ${duration.toFixed(2)}s`); + + const totalSuccess = sessions.reduce((sum, s) => sum + s.successCount, 0); + const totalFail = sessions.reduce((sum, s) => sum + s.failCount, 0); + const totalWs = sessions.reduce((sum, s) => sum + s.wsMessages, 0); + console.log(`总订单数: ${totalSuccess + totalFail} (成功: ${totalSuccess}, 失败: ${totalFail})`); + console.log(`总共接收 WS 消息数: ${totalWs}`); +} + +// 运行测试 +runLoadTest().catch(console.error); diff --git a/src/api/event.ts b/src/api/event.ts index 6d86b13..c04d2b7 100644 --- a/src/api/event.ts +++ b/src/api/event.ts @@ -146,8 +146,8 @@ export async function getPmEventPublic( if (tokenid != null) { query.tokenid = Array.isArray(tokenid) ? tokenid : [tokenid] } - if (tagId != null && Number.isFinite(tagId)) query.tagId = tagId - if (tagSlug != null && tagSlug !== '') query.tagSlug = tagSlug + // if (tagId != null && Number.isFinite(tagId)) query.tagId = tagId + // if (tagSlug != null && tagSlug !== '') query.tagSlug = tagSlug return get('/PmEvent/getPmEventPublic', query) } diff --git a/src/api/request.ts b/src/api/request.ts index 401ca96..ad5b776 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -7,6 +7,14 @@ const BASE_URL = ? (import.meta as unknown as { env: Record }).env.VITE_API_BASE_URL : 'https://api.xtrader.vip' +/** CLOB WebSocket URL,与 REST API 同源 */ +export function getClobWsUrl(): string { + const base = BASE_URL || (typeof window !== 'undefined' ? window.location.origin : 'https://api.xtrader.vip') + const url = new URL(base) + const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${url.host}/clob/ws` +} + export interface RequestConfig { /** 请求头,如 { 'x-token': token, 'x-user-id': userId } */ headers?: Record diff --git a/src/components/OrderBook.vue b/src/components/OrderBook.vue index 1883e70..7766fe7 100644 --- a/src/components/OrderBook.vue +++ b/src/components/OrderBook.vue @@ -4,7 +4,12 @@

Order Book

- $4.4k Vol. + + + Live + + 连接中... + $4.4k Vol. mdi-chevron-up
@@ -67,23 +72,45 @@