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:
co-authored by
Claude Opus 4.6
parent
991fd6caa4
commit
23bb74127c
+8
-11
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user