From 23bb74127cdf1376fe4d770c8c2492a0be86e013 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Fri, 6 Mar 2026 16:53:04 +0700 Subject: [PATCH] fix: rewrite makerWs to handle CLOB WebSocket message formats correctly - Handle initial book snapshot (arrives as array without event_type) - Handle price_change deltas (incremental updates to bid/ask levels) - Use Maps for O(1) price level updates instead of full array replace - Use proper WebSocket ping frames instead of string "ping" - Subscribe all assets in single message - Add getBestBid/getBestAsk methods using cached values from events - Improve TUI orderbook display with depth bars and spread indicator Co-Authored-By: Claude Opus 4.6 --- src/maker-bot.js | 19 ++- src/maker.js | 41 +++---- src/services/makerWs.js | 252 ++++++++++++++++++++++++++-------------- 3 files changed, 187 insertions(+), 125 deletions(-) diff --git a/src/maker-bot.js b/src/maker-bot.js index 44bf89a..6d9dfc7 100644 --- a/src/maker-bot.js +++ b/src/maker-bot.js @@ -70,18 +70,15 @@ async function printStatus() { ` | P&L: ${sign}$${pnl.toFixed(4)}`, ); - // Orderbook snapshot in sim mode - if (config.dryRun) { - for (const [label, tokenId] of [['UP', pos.up.tokenId], ['DOWN', pos.down.tokenId]]) { + // Orderbook snapshot + for (const [label, tokenId] of [['UP', pos.up.tokenId], ['DOWN', pos.down.tokenId]]) { + const bestBid = orderbookWs.getBestBid(tokenId); + const bestAsk = orderbookWs.getBestAsk(tokenId); + if (bestBid || bestAsk) { const book = orderbookWs.getBook(tokenId); - const bestBid = book.bids[0]; - const bestAsk = book.asks[0]; - if (bestBid || bestAsk) { - logger.info( - ` ${label} book: bid $${bestBid?.price.toFixed(3) || '-'} × ${bestBid?.size.toFixed(0) || '-'}` + - ` | ask $${bestAsk?.price.toFixed(3) || '-'} × ${bestAsk?.size.toFixed(0) || '-'}`, - ); - } + const bidDepth = book.bids.slice(0, 3).map(b => `${b.price.toFixed(2)}×${b.size.toFixed(0)}`).join(' '); + const askDepth = book.asks.slice(0, 3).map(a => `${a.price.toFixed(2)}×${a.size.toFixed(0)}`).join(' '); + logger.info(` ${label}: [${bidDepth}] | [${askDepth}]`); } } } diff --git a/src/maker.js b/src/maker.js index bcfdf45..19a53f9 100644 --- a/src/maker.js +++ b/src/maker.js @@ -126,44 +126,37 @@ async function buildStatusContent() { } } - // Orderbook display (simulation mode) - if (config.dryRun && activeWsTokens.up) { + // Orderbook display (always show when tokens are active) + if (activeWsTokens.up) { lines.push('{bold}LIVE ORDERBOOK{/bold}'); for (const [label, tokenId] of [['UP', activeWsTokens.up], ['DOWN', activeWsTokens.down]]) { if (!tokenId) continue; const book = orderbookWs.getBook(tokenId); - const lastPrice = orderbookWs.getLastPrice(tokenId); + const bestBid = orderbookWs.getBestBid(tokenId); + const bestAsk = orderbookWs.getBestAsk(tokenId); + const mid = bestBid && bestAsk ? ((bestBid + bestAsk) / 2) : 0; - lines.push(` {cyan-fg}${label}{/cyan-fg} (last: $${lastPrice.toFixed(3)})`); + lines.push(` {cyan-fg}${label}{/cyan-fg} mid: $${mid.toFixed(3)} | bid: $${bestBid.toFixed(2)} ask: $${bestAsk.toFixed(2)}`); - // Top 3 asks (reversed so lowest is closest to spread) - const topAsks = book.asks.slice(0, 3).reverse(); + // Top 5 asks (reversed so lowest is closest to spread) + const topAsks = book.asks.slice(0, 5).reverse(); for (const ask of topAsks) { - lines.push(` {red-fg}ASK $${ask.price.toFixed(3)} × ${ask.size.toFixed(0)}{/red-fg}`); + const bar = '█'.repeat(Math.min(10, Math.round(ask.size / 100))); + lines.push(` {red-fg}$${ask.price.toFixed(2)} ${ask.size.toFixed(0).padStart(7)} ${bar}{/red-fg}`); } - // Spread - const bestBid = book.bids[0]?.price || 0; - const bestAsk = book.asks[0]?.price || 0; + // Spread line if (bestBid && bestAsk) { - lines.push(` {gray-fg}--- spread: $${(bestAsk - bestBid).toFixed(3)} ---{/gray-fg}`); + const spread = bestAsk - bestBid; + lines.push(` {yellow-fg}── spread $${spread.toFixed(2)} ──{/yellow-fg}`); } - // Top 3 bids - const topBids = book.bids.slice(0, 3); + // Top 5 bids + const topBids = book.bids.slice(0, 5); for (const bid of topBids) { - lines.push(` {green-fg}BID $${bid.price.toFixed(3)} × ${bid.size.toFixed(0)}{/green-fg}`); - } - - // Recent trades - const trades = orderbookWs.getRecentTrades(tokenId, 3); - if (trades.length > 0) { - lines.push(` Trades:`); - for (const t of trades.reverse()) { - const color = t.side === 'BUY' ? 'green' : 'red'; - lines.push(` {${color}-fg}${t.side} $${t.price.toFixed(3)} × ${t.size.toFixed(0)}{/${color}-fg}`); - } + const bar = '█'.repeat(Math.min(10, Math.round(bid.size / 100))); + lines.push(` {green-fg}$${bid.price.toFixed(2)} ${bid.size.toFixed(0).padStart(7)} ${bar}{/green-fg}`); } lines.push(''); } diff --git a/src/services/makerWs.js b/src/services/makerWs.js index 9ae62b0..833c695 100644 --- a/src/services/makerWs.js +++ b/src/services/makerWs.js @@ -3,17 +3,17 @@ * WebSocket client for Polymarket CLOB orderbook + trade data. * Used by the simulation to display real-time orderbook and simulate fills. * - * Endpoints: - * - Book updates (bids/asks) - * - Last trade price - * - Trade history + * Message formats from CLOB WS: + * 1. Book snapshot (initial): [{asset_id, bids, asks, timestamp, hash}] (array, no event_type) + * 2. price_change: {event_type:"price_change", price_changes:[{asset_id, price, size, side, best_bid, best_ask}]} + * 3. last_trade_price: {event_type:"last_trade_price", asset_id, price} */ import WebSocket from 'ws'; import logger from '../utils/logger.js'; const WS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market'; -const PING_INTERVAL = 10_000; +const PING_INTERVAL = 30_000; const RECONNECT_DELAY = 3000; const MAX_RECONNECT_DELAY = 30_000; @@ -29,8 +29,13 @@ export class OrderbookWs { this.assetIds = []; this.conditionId = null; - // Orderbook state per asset - this.books = new Map(); // assetId → { bids: [], asks: [] } + // Orderbook state per asset: Map + this.bids = new Map(); // assetId → Map + this.asks = new Map(); // assetId → Map + + // Best bid/ask per asset (from price_change events) + this.bestBid = new Map(); // assetId → number + this.bestAsk = new Map(); // assetId → number // Recent trades per asset this.trades = new Map(); // assetId → [{ price, side, size, timestamp }] @@ -39,17 +44,24 @@ export class OrderbookWs { this.lastPrice = new Map(); // assetId → number // Callbacks - this.onBookUpdate = null; // (assetId, book) => void - this.onTradeUpdate = null; // (assetId, trade) => void - this.onPriceUpdate = null; // (assetId, price) => void + this.onBookUpdate = null; + this.onTradeUpdate = null; + this.onPriceUpdate = null; } subscribe(conditionId, assetIds) { + // Shutdown existing connection if any + if (this.ws) { + this.cleanup(false); + } + this.conditionId = conditionId; this.assetIds = assetIds; + this.isShutdown = false; for (const id of assetIds) { - this.books.set(id, { bids: [], asks: [] }); + this.bids.set(id, new Map()); + this.asks.set(id, new Map()); this.trades.set(id, []); } @@ -65,16 +77,15 @@ export class OrderbookWs { logger.info('MAKER WS: connected to orderbook feed'); this.reconnectDelay = RECONNECT_DELAY; - // Subscribe to book + trades for all assets - for (const assetId of this.assetIds) { - this.ws.send(JSON.stringify({ - auth: {}, - type: 'subscribe', - markets: [this.conditionId], - assets_ids: [assetId], - channels: ['book', 'trades'], - })); - } + // Subscribe to all assets in one message + const msg = { + auth: {}, + type: 'subscribe', + markets: [], + assets_ids: this.assetIds, + channels: ['book'], + }; + this.ws.send(JSON.stringify(msg)); this.startPing(); }); @@ -87,7 +98,8 @@ export class OrderbookWs { this.ws?.pong(); }); - this.ws.on('close', () => { + this.ws.on('close', (code, reason) => { + logger.warn(`MAKER WS: disconnected (${code})`); this.cleanup(true); }); @@ -102,61 +114,118 @@ export class OrderbookWs { try { msg = JSON.parse(raw.toString()); } catch { - const text = raw.toString().trim(); - if (text === 'ping') this.ws?.send('pong'); return; } - if (msg.type === 'ping' || msg === 'ping') { - this.ws?.send('pong'); + // Initial book snapshot comes as an array [{...}] + if (Array.isArray(msg)) { + for (const evt of msg) { + if (evt.asset_id && evt.bids) { + this.handleBookSnapshot(evt.asset_id, evt); + } + } return; } - // Handle different event types from the CLOB WS - const events = Array.isArray(msg) ? msg : [msg]; + // Subsequent messages are objects with event_type + switch (msg.event_type) { + case 'book': + if (msg.asset_id) { + this.handleBookSnapshot(msg.asset_id, msg); + } + break; - for (const evt of events) { - const assetId = evt.asset_id; + case 'price_change': + this.handlePriceChange(msg); + break; + + case 'last_trade_price': + if (msg.asset_id) { + this.handleLastPrice(msg.asset_id, msg); + } + break; + + default: + // Full book updates without event_type (non-array single object) + if (msg.asset_id && msg.bids) { + this.handleBookSnapshot(msg.asset_id, msg); + } + break; + } + } + + handleBookSnapshot(assetId, evt) { + if (!this.assetIds.includes(assetId)) return; + + // Replace entire book for this asset + const bidMap = new Map(); + for (const b of (evt.bids || [])) { + const price = parseFloat(b.price); + const size = parseFloat(b.size); + if (size > 0) bidMap.set(price, size); + } + this.bids.set(assetId, bidMap); + + const askMap = new Map(); + for (const a of (evt.asks || [])) { + const price = parseFloat(a.price); + const size = parseFloat(a.size); + if (size > 0) askMap.set(price, size); + } + this.asks.set(assetId, askMap); + + if (this.onBookUpdate) { + this.onBookUpdate(assetId, this.getBook(assetId)); + } + } + + handlePriceChange(msg) { + const changes = msg.price_changes || []; + + for (const change of changes) { + const assetId = change.asset_id; if (!assetId || !this.assetIds.includes(assetId)) continue; - switch (evt.event_type) { - case 'book': - this.handleBook(assetId, evt); - break; - case 'last_trade_price': - this.handleLastPrice(assetId, evt); - break; - case 'tick_size_change': - break; // ignore - default: - // Could be trade data - if (evt.price && evt.side) { - this.handleTrade(assetId, evt); + const price = parseFloat(change.price); + const size = parseFloat(change.size); + const side = change.side; // "BUY" = bid, "SELL" = ask + + if (side === 'BUY') { + const bidMap = this.bids.get(assetId); + if (bidMap) { + if (size > 0) { + bidMap.set(price, size); + } else { + bidMap.delete(price); // size 0 = remove level } - break; + } + } else if (side === 'SELL') { + const askMap = this.asks.get(assetId); + if (askMap) { + if (size > 0) { + askMap.set(price, size); + } else { + askMap.delete(price); + } + } + } + + // Update best bid/ask from the event + if (change.best_bid) this.bestBid.set(assetId, parseFloat(change.best_bid)); + if (change.best_ask) this.bestAsk.set(assetId, parseFloat(change.best_ask)); + } + + // Notify for each affected asset + const affectedAssets = new Set(changes.map(c => c.asset_id).filter(id => this.assetIds.includes(id))); + for (const assetId of affectedAssets) { + if (this.onBookUpdate) { + this.onBookUpdate(assetId, this.getBook(assetId)); } } } - handleBook(assetId, evt) { - const bids = (evt.bids || []).map((b) => ({ - price: parseFloat(b.price), - size: parseFloat(b.size), - })).sort((a, b) => b.price - a.price); - - const asks = (evt.asks || []).map((a) => ({ - price: parseFloat(a.price), - size: parseFloat(a.size), - })).sort((a, b) => a.price - b.price); - - this.books.set(assetId, { bids, asks, timestamp: evt.timestamp }); - - if (this.onBookUpdate) { - this.onBookUpdate(assetId, { bids, asks }); - } - } - handleLastPrice(assetId, evt) { + if (!this.assetIds.includes(assetId)) return; const price = parseFloat(evt.price || '0'); this.lastPrice.set(assetId, price); @@ -165,38 +234,31 @@ export class OrderbookWs { } } - handleTrade(assetId, evt) { - const trade = { - price: parseFloat(evt.price || '0'), - side: evt.side || '', - size: parseFloat(evt.size || evt.amount || '0'), - timestamp: evt.timestamp || new Date().toISOString(), - }; + /** + * Get sorted orderbook for an asset + */ + getBook(assetId) { + const bidMap = this.bids.get(assetId) || new Map(); + const askMap = this.asks.get(assetId) || new Map(); - const trades = this.trades.get(assetId) || []; - trades.push(trade); - if (trades.length > 50) trades.splice(0, trades.length - 50); - this.trades.set(assetId, trades); + const bids = Array.from(bidMap.entries()) + .map(([price, size]) => ({ price, size })) + .sort((a, b) => b.price - a.price); - if (this.onTradeUpdate) { - this.onTradeUpdate(assetId, trade); - } + const asks = Array.from(askMap.entries()) + .map(([price, size]) => ({ price, size })) + .sort((a, b) => a.price - b.price); + + return { bids, asks }; } /** * Check if a simulated order would fill based on current orderbook. - * @param {string} assetId - token ID - * @param {'buy'|'sell'} side - order side - * @param {number} price - limit price - * @param {number} size - order size - * @returns {{ filled: number, avgPrice: number } | null} */ checkSimFill(assetId, side, price, size) { - const book = this.books.get(assetId); - if (!book) return null; + const book = this.getBook(assetId); if (side === 'buy') { - // Buy order fills against asks at or below our price const eligible = book.asks.filter((a) => a.price <= price); if (eligible.length === 0) return null; @@ -213,7 +275,6 @@ export class OrderbookWs { return { filled: Math.min(filled, size), avgPrice: totalCost / filled }; } } else { - // Sell order fills against bids at or above our price const eligible = book.bids.filter((b) => b.price >= price); if (eligible.length === 0) return null; @@ -234,14 +295,25 @@ export class OrderbookWs { return null; } - getBook(assetId) { - return this.books.get(assetId) || { bids: [], asks: [] }; - } - getLastPrice(assetId) { return this.lastPrice.get(assetId) || 0; } + getBestBid(assetId) { + // Try from price_change data first, fallback to computed from book + const cached = this.bestBid.get(assetId); + if (cached) return cached; + const book = this.getBook(assetId); + return book.bids[0]?.price || 0; + } + + getBestAsk(assetId) { + const cached = this.bestAsk.get(assetId); + if (cached) return cached; + const book = this.getBook(assetId); + return book.asks[0]?.price || 0; + } + getRecentTrades(assetId, limit = 10) { const trades = this.trades.get(assetId) || []; return trades.slice(-limit); @@ -251,7 +323,7 @@ export class OrderbookWs { this.stopPing(); this.pingTimer = setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send('ping'); + this.ws.ping(); // Use proper WebSocket ping frames } }, PING_INTERVAL); }