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 <noreply@anthropic.com>
This commit is contained in:
direkturcrypto
2026-03-06 16:53:04 +07:00
co-authored by Claude Opus 4.6
parent 991fd6caa4
commit 23bb74127c
3 changed files with 187 additions and 125 deletions
+8 -11
View File
@@ -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}]`);
}
}
}
+17 -24
View File
@@ -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('');
}
+162 -90
View File
@@ -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<price, size>
this.bids = new Map(); // assetId → Map<price, size>
this.asks = new Map(); // assetId → Map<price, size>
// 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);
}