新增:websocket订单薄对接
This commit is contained in:
parent
41b349fd09
commit
2cb64ddb09
2
.env
2
.env
@ -1,6 +1,6 @@
|
|||||||
# API 基础地址,不设置时默认 https://api.xtrader.vip
|
# API 基础地址,不设置时默认 https://api.xtrader.vip
|
||||||
# 连接测试服务器 192.168.3.21:8888 时复制本文件为 .env 或 .env.local 并取消下一行注释:
|
# 连接测试服务器 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),可选覆盖
|
# SSH 部署(npm run deploy),可选覆盖
|
||||||
# DEPLOY_HOST=38.246.250.238
|
# DEPLOY_HOST=38.246.250.238
|
||||||
|
|||||||
@ -9,6 +9,7 @@ HTTP 请求基础封装,提供 `get` 和 `post` 方法,支持自定义请求
|
|||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- 统一 BASE_URL:默认 `https://api.xtrader.vip`,可通过环境变量 `VITE_API_BASE_URL` 覆盖
|
- 统一 BASE_URL:默认 `https://api.xtrader.vip`,可通过环境变量 `VITE_API_BASE_URL` 覆盖
|
||||||
|
- CLOB WebSocket URL:`getClobWsUrl()` 返回与 REST API 同源的 `ws(s)://host/api/clob/ws`
|
||||||
- GET 请求:支持 query 参数,自动序列化
|
- GET 请求:支持 query 参数,自动序列化
|
||||||
- POST 请求:支持 JSON body
|
- POST 请求:支持 JSON body
|
||||||
- 自定义 headers:通过 `RequestConfig.headers` 传入
|
- 自定义 headers:通过 `RequestConfig.headers` 传入
|
||||||
@ -16,7 +17,10 @@ HTTP 请求基础封装,提供 `get` 和 `post` 方法,支持自定义请求
|
|||||||
## 使用方式
|
## 使用方式
|
||||||
|
|
||||||
```typescript
|
```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 请求
|
// GET 请求
|
||||||
const data = await get<MyResponse>('/path', { page: 1, keyword: 'x' })
|
const data = await get<MyResponse>('/path', { page: 1, keyword: 'x' })
|
||||||
|
|||||||
@ -4,24 +4,44 @@
|
|||||||
|
|
||||||
## 功能用途
|
## 功能用途
|
||||||
|
|
||||||
订单簿组件,展示 Asks(卖单)与 Bids(买单)列表,含价格、份额、累计总量,以及横向进度条表示深度。当前使用 mock 数据。
|
订单簿组件,展示 Asks(卖单)与 Bids(买单)列表,含价格、份额、累计总量,以及横向进度条表示深度。支持通过 props 接收 CLOB WebSocket 实时数据,无 props 时回退到 mock 数据。
|
||||||
|
|
||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- Trade Up / Trade Down Tab
|
- Trade Up / Trade Down Tab
|
||||||
- Asks、Bids 列表,带 `HorizontalProgressBar` 深度条
|
- Asks、Bids 列表,带 `HorizontalProgressBar` 深度条
|
||||||
- Last price、Spread 展示
|
- 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
|
```vue
|
||||||
|
<!-- 由父组件传入 CLOB 数据(如 TradeDetail.vue) -->
|
||||||
|
<OrderBook
|
||||||
|
:asks="orderBookAsks"
|
||||||
|
:bids="orderBookBids"
|
||||||
|
:last-price="clobLastPrice"
|
||||||
|
:spread="clobSpread"
|
||||||
|
:loading="clobLoading"
|
||||||
|
:connected="clobConnected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 无 props 时使用 mock 数据 -->
|
||||||
<OrderBook />
|
<OrderBook />
|
||||||
```
|
```
|
||||||
|
|
||||||
当前为展示组件,无 props。后续可扩展为接收 `marketId` 等,从接口或 WebSocket 获取真实数据。
|
|
||||||
|
|
||||||
## 扩展方式
|
## 扩展方式
|
||||||
|
|
||||||
1. **真实数据**:接收 `asks`、`bids` props,或内部根据路由/context 拉取
|
1. **点击下单**:点击某行价格时,将价格传入 TradeComponent
|
||||||
2. **WebSocket**:订阅订单簿推送,实时更新
|
2. **Trade Up/Down**:可按 Yes/No Token 切换展示不同订单簿
|
||||||
3. **点击下单**:点击某行价格时,将价格传入 TradeComponent
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- 分时图:ECharts 渲染,支持 Past、时间粒度切换
|
- 分时图:ECharts 渲染,支持 Past、时间粒度切换
|
||||||
- 订单簿:`OrderBook` 组件
|
- 订单簿:`OrderBook` 组件,通过 **ClobSdk** 对接 CLOB WebSocket 实时数据(全量快照、增量更新、成交推送)
|
||||||
- 交易:`TradeComponent`,传入 `market`、`initialOption`
|
- 交易:`TradeComponent`,传入 `market`、`initialOption`
|
||||||
- 移动端:底部栏 + `v-bottom-sheet` 嵌入 `TradeComponent`
|
- 移动端:底部栏 + `v-bottom-sheet` 嵌入 `TradeComponent`
|
||||||
- Merge/Split:通过 `TradeComponent` 或底部菜单触发
|
- Merge/Split:通过 `TradeComponent` 或底部菜单触发
|
||||||
@ -22,7 +22,8 @@
|
|||||||
|
|
||||||
## 扩展方式
|
## 扩展方式
|
||||||
|
|
||||||
1. **实时数据**:订单簿、分时图接入 WebSocket
|
1. **订单簿**:已通过 `sdk/clobSocket.ts` 的 ClobSdk 对接 CLOB WebSocket,使用 **Yes/No token ID** 订阅 `price_size_all`、`price_size_delta`、`trade` 消息
|
||||||
2. **Comments**:对接评论接口,替换 placeholder
|
2. **分时图**:可接入 WebSocket 推送的图表数据
|
||||||
3. **Top Holders**:对接持仓接口
|
3. **Comments**:对接评论接口,替换 placeholder
|
||||||
4. **Activity**:对接交易活动接口,替换 mock 数据
|
4. **Top Holders**:对接持仓接口
|
||||||
|
5. **Activity**:已对接 CLOB `trade` 消息,实时追加成交记录
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -16,7 +16,8 @@
|
|||||||
"siwe": "^3.0.0",
|
"siwe": "^3.0.0",
|
||||||
"vue": "^3.5.27",
|
"vue": "^3.5.27",
|
||||||
"vue-router": "^5.0.1",
|
"vue-router": "^5.0.1",
|
||||||
"vuetify": "^4.0.0-beta.0"
|
"vuetify": "^4.0.0-beta.0",
|
||||||
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.1",
|
"@playwright/test": "^1.58.1",
|
||||||
@ -9085,7 +9086,6 @@
|
|||||||
"version": "8.19.0",
|
"version": "8.19.0",
|
||||||
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz",
|
||||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|||||||
@ -26,7 +26,8 @@
|
|||||||
"siwe": "^3.0.0",
|
"siwe": "^3.0.0",
|
||||||
"vue": "^3.5.27",
|
"vue": "^3.5.27",
|
||||||
"vue-router": "^5.0.1",
|
"vue-router": "^5.0.1",
|
||||||
"vuetify": "^4.0.0-beta.0"
|
"vuetify": "^4.0.0-beta.0",
|
||||||
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.1",
|
"@playwright/test": "^1.58.1",
|
||||||
|
|||||||
0
sdk/.gitkeep
Normal file
0
sdk/.gitkeep
Normal file
296
sdk/clobSocket.ts
Normal file
296
sdk/clobSocket.ts
Normal file
@ -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<string, number>; // 买单: 价格 -> 数量
|
||||||
|
s?: Record<string, number>; // 卖单: 价格 -> 数量
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
sdk/index.ts
Normal file
91
sdk/index.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
320
sdk/loadTest.ts
Normal file
320
sdk/loadTest.ts
Normal file
@ -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<UserSession | null> {
|
||||||
|
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<void>((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);
|
||||||
@ -146,8 +146,8 @@ export async function getPmEventPublic(
|
|||||||
if (tokenid != null) {
|
if (tokenid != null) {
|
||||||
query.tokenid = Array.isArray(tokenid) ? tokenid : [tokenid]
|
query.tokenid = Array.isArray(tokenid) ? tokenid : [tokenid]
|
||||||
}
|
}
|
||||||
if (tagId != null && Number.isFinite(tagId)) query.tagId = tagId
|
// if (tagId != null && Number.isFinite(tagId)) query.tagId = tagId
|
||||||
if (tagSlug != null && tagSlug !== '') query.tagSlug = tagSlug
|
// if (tagSlug != null && tagSlug !== '') query.tagSlug = tagSlug
|
||||||
|
|
||||||
return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query)
|
return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,14 @@ const BASE_URL =
|
|||||||
? (import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_URL
|
? (import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_URL
|
||||||
: 'https://api.xtrader.vip'
|
: '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 {
|
export interface RequestConfig {
|
||||||
/** 请求头,如 { 'x-token': token, 'x-user-id': userId } */
|
/** 请求头,如 { 'x-token': token, 'x-user-id': userId } */
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
|
|||||||
@ -4,7 +4,12 @@
|
|||||||
<div class="order-book-header">
|
<div class="order-book-header">
|
||||||
<h3 class="order-book-title">Order Book</h3>
|
<h3 class="order-book-title">Order Book</h3>
|
||||||
<div class="order-book-vol">
|
<div class="order-book-vol">
|
||||||
$4.4k Vol.
|
<span v-if="connected" class="live-badge">
|
||||||
|
<span class="live-dot"></span>
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
<span v-else-if="loading" class="loading-badge">连接中...</span>
|
||||||
|
<span v-else class="vol-text">$4.4k Vol.</span>
|
||||||
<v-icon size="14" class="order-book-icon">mdi-chevron-up</v-icon>
|
<v-icon size="14" class="order-book-icon">mdi-chevron-up</v-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -67,23 +72,45 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="order-book-footer">
|
<div class="order-book-footer">
|
||||||
<div class="last-price">Last: {{ lastPrice }}¢</div>
|
<div class="last-price">Last: {{ displayLastPrice }}¢</div>
|
||||||
<div class="spread">Spread: {{ spread }}¢</div>
|
<div class="spread">Spread: {{ displaySpread }}¢</div>
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import HorizontalProgressBar from './HorizontalProgressBar.vue'
|
import HorizontalProgressBar from './HorizontalProgressBar.vue'
|
||||||
|
|
||||||
|
export interface OrderBookRow {
|
||||||
|
price: number
|
||||||
|
shares: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
asks?: OrderBookRow[]
|
||||||
|
bids?: OrderBookRow[]
|
||||||
|
lastPrice?: number
|
||||||
|
spread?: number
|
||||||
|
loading?: boolean
|
||||||
|
connected?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
asks: () => [],
|
||||||
|
bids: () => [],
|
||||||
|
lastPrice: undefined,
|
||||||
|
spread: undefined,
|
||||||
|
loading: false,
|
||||||
|
connected: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const activeTrade = ref('up')
|
const activeTrade = ref('up')
|
||||||
const lastPrice = ref(37)
|
|
||||||
const spread = ref(1)
|
|
||||||
|
|
||||||
// Mock asks data (卖出订单)
|
// 使用 props 或回退到 mock 数据
|
||||||
const asks = ref([
|
const internalAsks = ref<OrderBookRow[]>([
|
||||||
{ price: 45, shares: 1000.0 },
|
{ price: 45, shares: 1000.0 },
|
||||||
{ price: 44, shares: 2500.0 },
|
{ price: 44, shares: 2500.0 },
|
||||||
{ price: 43, shares: 1800.0 },
|
{ price: 43, shares: 1800.0 },
|
||||||
@ -94,9 +121,7 @@ const asks = ref([
|
|||||||
{ price: 38, shares: 500.0 },
|
{ price: 38, shares: 500.0 },
|
||||||
{ price: 37, shares: 300.0 },
|
{ price: 37, shares: 300.0 },
|
||||||
])
|
])
|
||||||
|
const internalBids = ref<OrderBookRow[]>([
|
||||||
// Mock bids data (买入订单)
|
|
||||||
const bids = ref([
|
|
||||||
{ price: 36, shares: 200.0 },
|
{ price: 36, shares: 200.0 },
|
||||||
{ price: 35, shares: 500.0 },
|
{ price: 35, shares: 500.0 },
|
||||||
{ price: 34, shares: 1000.0 },
|
{ price: 34, shares: 1000.0 },
|
||||||
@ -110,36 +135,54 @@ const bids = ref([
|
|||||||
{ price: 26, shares: 1500.0 },
|
{ price: 26, shares: 1500.0 },
|
||||||
{ price: 25, shares: 1000.0 },
|
{ price: 25, shares: 1000.0 },
|
||||||
])
|
])
|
||||||
|
const internalLastPrice = ref(37)
|
||||||
|
const internalSpread = ref(1)
|
||||||
|
|
||||||
// Simulate dynamic data updates
|
// 当有外部数据时使用 props,否则用 mock
|
||||||
setInterval(() => {
|
const asks = computed(() =>
|
||||||
// Update random ask price and shares
|
props.asks?.length ? props.asks : internalAsks.value,
|
||||||
const randomAskIndex = Math.floor(Math.random() * asks.value.length)
|
)
|
||||||
const askItem = asks.value[randomAskIndex]
|
const bids = computed(() =>
|
||||||
|
props.bids?.length ? props.bids : internalBids.value,
|
||||||
|
)
|
||||||
|
const displayLastPrice = computed(() =>
|
||||||
|
props.lastPrice ?? internalLastPrice.value,
|
||||||
|
)
|
||||||
|
const displaySpread = computed(() => props.spread ?? internalSpread.value)
|
||||||
|
|
||||||
|
// 仅在没有外部数据时运行 mock 更新
|
||||||
|
let mockInterval: ReturnType<typeof setInterval> | undefined
|
||||||
|
watch(
|
||||||
|
() => props.connected || (props.asks?.length ?? 0) > 0,
|
||||||
|
(hasRealData) => {
|
||||||
|
if (hasRealData && mockInterval) {
|
||||||
|
clearInterval(mockInterval)
|
||||||
|
mockInterval = undefined
|
||||||
|
} else if (!hasRealData && !mockInterval) {
|
||||||
|
mockInterval = setInterval(() => {
|
||||||
|
const randomAskIndex = Math.floor(Math.random() * internalAsks.value.length)
|
||||||
|
const askItem = internalAsks.value[randomAskIndex]
|
||||||
if (askItem) {
|
if (askItem) {
|
||||||
asks.value[randomAskIndex] = {
|
internalAsks.value[randomAskIndex] = {
|
||||||
price: askItem.price,
|
price: askItem.price,
|
||||||
shares: Math.max(0, askItem.shares + Math.floor(Math.random() * 100) - 50),
|
shares: Math.max(0, askItem.shares + Math.floor(Math.random() * 100) - 50),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const randomBidIndex = Math.floor(Math.random() * internalBids.value.length)
|
||||||
// Update random bid price and shares
|
const bidItem = internalBids.value[randomBidIndex]
|
||||||
const randomBidIndex = Math.floor(Math.random() * bids.value.length)
|
|
||||||
const bidItem = bids.value[randomBidIndex]
|
|
||||||
if (bidItem) {
|
if (bidItem) {
|
||||||
bids.value[randomBidIndex] = {
|
internalBids.value[randomBidIndex] = {
|
||||||
price: bidItem.price,
|
price: bidItem.price,
|
||||||
shares: Math.max(0, bidItem.shares + Math.floor(Math.random() * 100) - 50),
|
shares: Math.max(0, bidItem.shares + Math.floor(Math.random() * 100) - 50),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
internalLastPrice.value = Math.max(1, Math.min(99, internalLastPrice.value + Math.floor(Math.random() * 3) - 1))
|
||||||
// Update last price
|
internalSpread.value = Math.max(1, internalSpread.value + Math.floor(Math.random() * 2) - 1)
|
||||||
lastPrice.value = lastPrice.value + Math.floor(Math.random() * 3) - 1
|
}, 2000)
|
||||||
|
}
|
||||||
// Update spread
|
},
|
||||||
spread.value = spread.value + Math.floor(Math.random() * 2) - 1
|
{ immediate: true },
|
||||||
if (spread.value < 1) spread.value = 1
|
)
|
||||||
}, 2000) // Update every 2 seconds
|
|
||||||
|
|
||||||
// Calculate cumulative total for asks
|
// Calculate cumulative total for asks
|
||||||
const asksWithCumulativeTotal = computed(() => {
|
const asksWithCumulativeTotal = computed(() => {
|
||||||
@ -238,6 +281,41 @@ const maxBidsTotal = computed(() => {
|
|||||||
color: #666666;
|
color: #666666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #059669;
|
||||||
|
animation: live-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes live-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-badge {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vol-text {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.trade-tabs-container {
|
.trade-tabs-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -1718,9 +1718,16 @@ async function submitOrder() {
|
|||||||
orderError.value = '请先登录'
|
orderError.value = '请先登录'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const uid = userStore.user?.id ?? userStore.user?.ID
|
const uid = userStore?.user?.ID ?? 0
|
||||||
const userIdNum = uid != null ? Number(uid) : 0
|
console.log('[submitOrder] 用户信息: user=', userStore.user, 'uid=', uid)
|
||||||
|
const userIdNum =
|
||||||
|
typeof uid === 'number'
|
||||||
|
? uid
|
||||||
|
: uid != null
|
||||||
|
? parseInt(String(uid), 10)
|
||||||
|
: 0
|
||||||
if (!Number.isFinite(userIdNum) || userIdNum <= 0) {
|
if (!Number.isFinite(userIdNum) || userIdNum <= 0) {
|
||||||
|
console.warn('[submitOrder] 用户信息异常: user=', userStore.user, 'uid=', uid)
|
||||||
orderError.value = '用户信息异常'
|
orderError.value = '用户信息异常'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,10 +52,25 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
|
|
||||||
function setUser(loginData: { token?: string; user?: UserInfo }) {
|
function setUser(loginData: { token?: string; user?: UserInfo }) {
|
||||||
const t = loginData.token ?? ''
|
const t = loginData.token ?? ''
|
||||||
const u = loginData.user ?? null
|
const raw = loginData.user ?? null
|
||||||
token.value = t
|
token.value = t
|
||||||
user.value = u
|
if (raw) {
|
||||||
if (t && u) saveToStorage(t, u)
|
const rawId = raw.ID ?? raw.id
|
||||||
|
const numId =
|
||||||
|
typeof rawId === 'number'
|
||||||
|
? rawId
|
||||||
|
: rawId != null
|
||||||
|
? parseInt(String(rawId), 10)
|
||||||
|
: undefined
|
||||||
|
user.value = {
|
||||||
|
...raw,
|
||||||
|
id: rawId ?? numId,
|
||||||
|
ID: Number.isFinite(numId) ? numId : raw.ID,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
if (t && user.value) saveToStorage(t, user.value)
|
||||||
else clearStorage()
|
else clearStorage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,8 +82,8 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
|
|
||||||
/** 鉴权请求头:x-token 与 x-user-id,未登录时返回 undefined */
|
/** 鉴权请求头:x-token 与 x-user-id,未登录时返回 undefined */
|
||||||
function getAuthHeaders(): Record<string, string> | undefined {
|
function getAuthHeaders(): Record<string, string> | undefined {
|
||||||
if (!token.value || !user.value) return undefined
|
if (!token.value) return undefined
|
||||||
const uid = user.value.id ?? user.value.ID
|
const uid = user.value?.id ?? user.value?.ID
|
||||||
return {
|
return {
|
||||||
'x-token': token.value,
|
'x-token': token.value,
|
||||||
...(uid != null && uid !== '' && { 'x-user-id': String(uid) }),
|
...(uid != null && uid !== '' && { 'x-user-id': String(uid) }),
|
||||||
@ -97,16 +112,27 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const res = await getUserInfo(headers)
|
const res = await getUserInfo(headers)
|
||||||
console.log('[fetchUserInfo] 接口响应:', JSON.stringify(res, null, 2))
|
console.log('[fetchUserInfo] 接口响应:', JSON.stringify(res, null, 2))
|
||||||
const data = res.data as Record<string, unknown> | undefined
|
const data = res.data as Record<string, unknown> | undefined
|
||||||
const u = (data?.user ?? data) as Record<string, unknown>
|
// 接口返回 data.userInfo 或 data.user,取实际用户对象;若仍含 userInfo 则再取一层
|
||||||
|
let u = (data?.userInfo ?? data?.user ?? data) as Record<string, unknown>
|
||||||
|
if (u?.userInfo && (u.ID == null && u.id == null)) {
|
||||||
|
u = u.userInfo as Record<string, unknown>
|
||||||
|
}
|
||||||
if ((res.code === 0 || res.code === 200) && u) {
|
if ((res.code === 0 || res.code === 200) && u) {
|
||||||
|
const rawId = u.ID ?? u.id
|
||||||
|
const numId =
|
||||||
|
typeof rawId === 'number'
|
||||||
|
? rawId
|
||||||
|
: rawId != null
|
||||||
|
? parseInt(String(rawId), 10)
|
||||||
|
: undefined
|
||||||
user.value = {
|
user.value = {
|
||||||
id: u.ID as number | string | undefined,
|
...u,
|
||||||
ID: u.ID as number | undefined,
|
|
||||||
userName: (u.userName ?? u.username) as string | undefined,
|
userName: (u.userName ?? u.username) as string | undefined,
|
||||||
nickName: (u.nickName ?? u.nickname) as string | undefined,
|
nickName: (u.nickName ?? u.nickname) as string | undefined,
|
||||||
headerImg: (u.headerImg ?? u.avatar ?? u.avatarUrl) as string | undefined,
|
headerImg: (u.headerImg ?? u.avatar ?? u.avatarUrl) as string | undefined,
|
||||||
...u,
|
id: (rawId ?? numId) as number | string | undefined,
|
||||||
}
|
ID: Number.isFinite(numId) ? numId : undefined,
|
||||||
|
} as UserInfo
|
||||||
if (token.value && user.value) saveToStorage(token.value, user.value)
|
if (token.value && user.value) saveToStorage(token.value, user.value)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -181,7 +181,15 @@ const connectWithWallet = async () => {
|
|||||||
console.log('[walletLogin] 登录响应:', JSON.stringify(loginData, null, 2))
|
console.log('[walletLogin] 登录响应:', JSON.stringify(loginData, null, 2))
|
||||||
|
|
||||||
if (loginData.code === 0 && loginData.data) {
|
if (loginData.code === 0 && loginData.data) {
|
||||||
const { token, user } = loginData.data
|
const data = loginData.data as Record<string, unknown>
|
||||||
|
const token = data.token as string | undefined
|
||||||
|
// 兼容 data.user 或 data 本身即用户对象(部分接口将 user 平铺在 data 下)
|
||||||
|
const user =
|
||||||
|
(data.user as UserInfo | undefined) ??
|
||||||
|
(data.ID != null || data.id != null || data.userName != null ? (data as unknown as UserInfo) : undefined)
|
||||||
|
if (!user) {
|
||||||
|
console.warn('[walletLogin] data 中无 user,将依赖 fetchUserInfo 拉取。data 结构:', Object.keys(data))
|
||||||
|
}
|
||||||
console.log('[walletLogin] 存入 store 的 user:', JSON.stringify(user, null, 2))
|
console.log('[walletLogin] 存入 store 的 user:', JSON.stringify(user, null, 2))
|
||||||
userStore.setUser({ token, user })
|
userStore.setUser({ token, user })
|
||||||
await userStore.fetchUserInfo()
|
await userStore.fetchUserInfo()
|
||||||
|
|||||||
@ -46,7 +46,14 @@
|
|||||||
|
|
||||||
<!-- Order Book Section -->
|
<!-- Order Book Section -->
|
||||||
<v-card class="order-book-card" elevation="0" rounded="lg" style="margin-top: 32px">
|
<v-card class="order-book-card" elevation="0" rounded="lg" style="margin-top: 32px">
|
||||||
<OrderBook />
|
<OrderBook
|
||||||
|
:asks="orderBookAsks"
|
||||||
|
:bids="orderBookBids"
|
||||||
|
:last-price="clobLastPrice"
|
||||||
|
:spread="clobSpread"
|
||||||
|
:loading="clobLoading"
|
||||||
|
:connected="clobConnected"
|
||||||
|
/>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- Comments / Top Holders / Activity(与左侧图表、订单簿同宽) -->
|
<!-- Comments / Top Holders / Activity(与左侧图表、订单簿同宽) -->
|
||||||
@ -192,10 +199,17 @@ import TradeComponent from '../components/TradeComponent.vue'
|
|||||||
import {
|
import {
|
||||||
findPmEvent,
|
findPmEvent,
|
||||||
getMarketId,
|
getMarketId,
|
||||||
|
getClobTokenId,
|
||||||
type FindPmEventParams,
|
type FindPmEventParams,
|
||||||
type PmEventListItem,
|
type PmEventListItem,
|
||||||
} from '../api/event'
|
} from '../api/event'
|
||||||
|
import { getClobWsUrl } from '../api/request'
|
||||||
import { useUserStore } from '../stores/user'
|
import { useUserStore } from '../stores/user'
|
||||||
|
import {
|
||||||
|
ClobSdk,
|
||||||
|
type PriceSizePolyMsg,
|
||||||
|
type TradePolyMsg,
|
||||||
|
} from '../../sdk/clobSocket'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分时图服务端推送数据格式约定
|
* 分时图服务端推送数据格式约定
|
||||||
@ -318,6 +332,121 @@ const currentMarket = computed(() => {
|
|||||||
return list[0]
|
return list[0]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- CLOB WebSocket 订单簿与成交 ---
|
||||||
|
const clobSdkRef = ref<ClobSdk | null>(null)
|
||||||
|
const orderBookAsks = ref<{ price: number; shares: number }[]>([])
|
||||||
|
const orderBookBids = ref<{ price: number; shares: number }[]>([])
|
||||||
|
const clobLastPrice = ref<number | undefined>(undefined)
|
||||||
|
const clobSpread = ref<number | undefined>(undefined)
|
||||||
|
const clobConnected = ref(false)
|
||||||
|
const clobLoading = ref(false)
|
||||||
|
|
||||||
|
function priceSizeToRows(record: Record<string, number> | undefined): { price: number; shares: number }[] {
|
||||||
|
if (!record) return []
|
||||||
|
return Object.entries(record)
|
||||||
|
.filter(([, shares]) => shares > 0)
|
||||||
|
.map(([p, shares]) => ({
|
||||||
|
price: Math.round(parseFloat(p) /100),
|
||||||
|
shares,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPriceSizeAll(msg: PriceSizePolyMsg) {
|
||||||
|
orderBookAsks.value = priceSizeToRows(msg.s).sort((a, b) => a.price - b.price)
|
||||||
|
orderBookBids.value = priceSizeToRows(msg.b).sort((a, b) => b.price - a.price)
|
||||||
|
updateSpreadFromBook()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPriceSizeDelta(msg: PriceSizePolyMsg) {
|
||||||
|
const mergeDelta = (
|
||||||
|
current: { price: number; shares: number }[],
|
||||||
|
delta: Record<string, number> | undefined,
|
||||||
|
asc: boolean,
|
||||||
|
) => {
|
||||||
|
const map = new Map(current.map((r) => [r.price, r.shares]))
|
||||||
|
if (delta) {
|
||||||
|
Object.entries(delta).forEach(([p, shares]) => {
|
||||||
|
const price = Math.round(parseFloat(p) / 100)
|
||||||
|
if (shares <= 0) map.delete(price)
|
||||||
|
else map.set(price, shares)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.map(([price, shares]) => ({ price, shares }))
|
||||||
|
.sort((a, b) => (asc ? a.price - b.price : b.price - a.price))
|
||||||
|
}
|
||||||
|
orderBookAsks.value = mergeDelta(orderBookAsks.value, msg.s, true)
|
||||||
|
orderBookBids.value = mergeDelta(orderBookBids.value, msg.b, false)
|
||||||
|
updateSpreadFromBook()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSpreadFromBook() {
|
||||||
|
const bestAsk = orderBookAsks.value[0]?.price
|
||||||
|
const bestBid = orderBookBids.value[0]?.price
|
||||||
|
if (bestAsk != null && bestBid != null) {
|
||||||
|
clobSpread.value = bestAsk - bestBid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectClob(tokenIds: string[]) {
|
||||||
|
clobSdkRef.value?.disconnect()
|
||||||
|
clobSdkRef.value = null
|
||||||
|
clobLoading.value = true
|
||||||
|
clobConnected.value = false
|
||||||
|
orderBookAsks.value = []
|
||||||
|
orderBookBids.value = []
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
url: getClobWsUrl(),
|
||||||
|
autoReconnect: true,
|
||||||
|
reconnectInterval: 2000,
|
||||||
|
}
|
||||||
|
const sdk = new ClobSdk(tokenIds, options)
|
||||||
|
|
||||||
|
sdk.onConnect(() => {
|
||||||
|
clobConnected.value = true
|
||||||
|
clobLoading.value = false
|
||||||
|
})
|
||||||
|
sdk.onDisconnect(() => {
|
||||||
|
clobConnected.value = false
|
||||||
|
})
|
||||||
|
sdk.onError(() => {
|
||||||
|
clobLoading.value = false
|
||||||
|
})
|
||||||
|
sdk.onPriceSizeAll(applyPriceSizeAll)
|
||||||
|
sdk.onPriceSizeDelta(applyPriceSizeDelta)
|
||||||
|
sdk.onTrade((msg: TradePolyMsg) => {
|
||||||
|
const priceNum = parseFloat(msg.p)
|
||||||
|
if (Number.isFinite(priceNum)) {
|
||||||
|
clobLastPrice.value = Math.round(priceNum * 100)
|
||||||
|
}
|
||||||
|
// 追加到 Activity 列表
|
||||||
|
const side = msg.side?.toLowerCase() === 'buy' ? 'Yes' : 'No'
|
||||||
|
const action = side === 'Yes' ? 'bought' : 'sold'
|
||||||
|
activityList.value.unshift({
|
||||||
|
id: `clob-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
user: '0x...',
|
||||||
|
avatarClass: 'avatar-gradient-1',
|
||||||
|
action: action as 'bought' | 'sold',
|
||||||
|
side: side as 'Yes' | 'No',
|
||||||
|
amount: Math.round(parseFloat(msg.s) || 0),
|
||||||
|
price: `${Math.round((priceNum || 0) * 100)}¢`,
|
||||||
|
total: `$${((parseFloat(msg.s) || 0) * (priceNum || 0)).toFixed(2)}`,
|
||||||
|
time: Date.now(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
clobSdkRef.value = sdk
|
||||||
|
sdk.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectClob() {
|
||||||
|
clobSdkRef.value?.disconnect()
|
||||||
|
clobSdkRef.value = null
|
||||||
|
clobConnected.value = false
|
||||||
|
clobLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
/** 传给 TradeComponent 的 market,供 Split 调用 /PmMarket/split;接口未返回时用 query 兜底 */
|
/** 传给 TradeComponent 的 market,供 Split 调用 /PmMarket/split;接口未返回时用 query 兜底 */
|
||||||
const tradeMarketPayload = computed(() => {
|
const tradeMarketPayload = computed(() => {
|
||||||
const m = currentMarket.value
|
const m = currentMarket.value
|
||||||
@ -748,6 +877,31 @@ function stopDynamicUpdate() {
|
|||||||
|
|
||||||
watch(selectedTimeRange, () => updateChartData())
|
watch(selectedTimeRange, () => updateChartData())
|
||||||
|
|
||||||
|
// CLOB:当有 market 且存在 clobTokenIds 时连接(使用 Yes/No token ID)
|
||||||
|
const clobTokenIds = computed(() => {
|
||||||
|
const m = currentMarket.value
|
||||||
|
if (!m?.clobTokenIds?.length) return []
|
||||||
|
const yes = getClobTokenId(m, 0)
|
||||||
|
const no = getClobTokenId(m, 1)
|
||||||
|
return [yes, no].filter((id): id is string => !!id)
|
||||||
|
})
|
||||||
|
watch(
|
||||||
|
clobTokenIds,
|
||||||
|
(tokenIds) => {
|
||||||
|
if (tokenIds.length > 0) {
|
||||||
|
// 用接口返回的 Yes 价格作为初始 lastPrice
|
||||||
|
const payload = tradeMarketPayload.value
|
||||||
|
if (payload?.yesPrice != null) {
|
||||||
|
clobLastPrice.value = Math.round(payload.yesPrice * 100)
|
||||||
|
}
|
||||||
|
connectClob(tokenIds)
|
||||||
|
} else {
|
||||||
|
disconnectClob()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (!chartInstance || !chartContainerRef.value) return
|
if (!chartInstance || !chartContainerRef.value) return
|
||||||
chartInstance.resize()
|
chartInstance.resize()
|
||||||
@ -774,6 +928,7 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
chartInstance?.dispose()
|
chartInstance?.dispose()
|
||||||
chartInstance = null
|
chartInstance = null
|
||||||
|
disconnectClob()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user