From d7dabea20f62ca3faabdd6b3d70965bef3adffce Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Sun, 15 Mar 2026 15:06:23 -0700 Subject: [PATCH] Fix: robust quote/orderbook parsing --- lib/market/tracker.js | 221 +++++++++++++++++++++++++++++++++--------- 1 file changed, 175 insertions(+), 46 deletions(-) diff --git a/lib/market/tracker.js b/lib/market/tracker.js index e94f8c5..9372862 100644 --- a/lib/market/tracker.js +++ b/lib/market/tracker.js @@ -24,16 +24,11 @@ export class MarketTracker extends EventEmitter { async start() { console.log('[Tracker] Starting market tracker...'); - // Connect WebSocket this.ws.connect(); - this.ws.on('orderbook', (msg) => this._onOrderbook(msg)); this.ws.on('ticker', (msg) => this._onTicker(msg)); - // Initial market discovery await this._findAndSubscribe(); - - // Check for market rotation every 30 seconds this.rotateInterval = setInterval(() => this._checkRotation(), 30000); } @@ -45,14 +40,34 @@ export class MarketTracker extends EventEmitter { getState() { if (!this.marketData) return null; - const yesAsk = this.orderbook.yes?.[0]?.[0] || this.marketData.yes_ask; - const noAsk = this.orderbook.no?.[0]?.[0] || this.marketData.no_ask; + const quotes = this._extractMarketQuotes(this.marketData); + const bestYesBook = this._bestBookPrice(this.orderbook.yes); + const bestNoBook = this._bestBookPrice(this.orderbook.no); - // Prices on Kalshi are in cents (1-99) - const yesPct = yesAsk || 50; - const noPct = noAsk || 50; + const yesBid = quotes.yesBid ?? bestYesBook; + const noBid = quotes.noBid ?? bestNoBook; + const yesAsk = quotes.yesAsk ?? (noBid != null ? 100 - noBid : null); + const noAsk = quotes.noAsk ?? (yesBid != null ? 100 - yesBid : null); + + let yesPct = yesAsk ?? yesBid ?? bestYesBook; + let noPct = noAsk ?? noBid ?? bestNoBook; + + if (yesPct == null && noPct != null) yesPct = 100 - noPct; + if (noPct == null && yesPct != null) noPct = 100 - yesPct; + + if (yesPct == null && noPct == null && quotes.lastPrice != null) { + yesPct = quotes.lastPrice; + noPct = 100 - quotes.lastPrice; + } + + if (yesPct == null || noPct == null) { + yesPct = 50; + noPct = 50; + } + + yesPct = this._clampPct(yesPct) ?? 50; + noPct = this._clampPct(noPct) ?? 50; - // Odds = 100 / price const yesOdds = yesPct > 0 ? (100 / yesPct).toFixed(2) : '0.00'; const noOdds = noPct > 0 ? (100 / noPct).toFixed(2) : '0.00'; @@ -65,14 +80,14 @@ export class MarketTracker extends EventEmitter { noPct, yesOdds: parseFloat(yesOdds), noOdds: parseFloat(noOdds), - yesBid: this.marketData.yes_bid, - yesAsk: this.marketData.yes_ask, - noBid: this.marketData.no_bid, - noAsk: this.marketData.no_ask, - volume: this.marketData.volume || 0, - volume24h: this.marketData.volume_24h || 0, - openInterest: this.marketData.open_interest || 0, - lastPrice: this.marketData.last_price, + yesBid: this._clampPct(yesBid), + yesAsk: this._clampPct(yesAsk), + noBid: this._clampPct(noBid), + noAsk: this._clampPct(noAsk), + volume: this._num(this.marketData.volume) ?? 0, + volume24h: this._num(this.marketData.volume_24h) ?? 0, + openInterest: this._num(this.marketData.open_interest) ?? 0, + lastPrice: this._clampPct(quotes.lastPrice), closeTime: this.marketData.close_time || this.marketData.expiration_time, status: this.marketData.status, result: this.marketData.result, @@ -80,12 +95,82 @@ export class MarketTracker extends EventEmitter { }; } + _num(value) { + if (value == null) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + + _clampPct(value) { + const n = this._num(value); + if (n == null) return null; + return Math.max(0, Math.min(100, n)); + } + _toTs(value) { if (!value) return null; const ts = new Date(value).getTime(); return Number.isFinite(ts) ? ts : null; } + _extractMarketQuotes(market) { + const pick = (...keys) => { + for (const key of keys) { + const v = this._num(market?.[key]); + if (v != null) return v; + } + return null; + }; + + return { + yesBid: pick('yes_bid', 'yesBid'), + yesAsk: pick('yes_ask', 'yesAsk'), + noBid: pick('no_bid', 'noBid'), + noAsk: pick('no_ask', 'noAsk'), + lastPrice: pick('last_price', 'lastPrice', 'yes_price', 'yesPrice') + }; + } + + _normalizeBookSide(levels) { + if (!Array.isArray(levels)) return []; + + const out = []; + + for (const level of levels) { + let price = null; + let qty = null; + + if (Array.isArray(level)) { + price = level[0]; + qty = level[1]; + } else if (level && typeof level === 'object') { + price = level.price ?? level[0]; + qty = level.qty ?? level.quantity ?? level.size ?? level.count ?? level[1]; + } + + const p = this._num(price); + const q = this._num(qty); + + if (p == null || q == null || q <= 0) continue; + out.push([p, q]); + } + + return out.sort((a, b) => b[0] - a[0]); + } + + _normalizeOrderbook(book) { + const root = book?.orderbook && typeof book.orderbook === 'object' ? book.orderbook : book; + return { + yes: this._normalizeBookSide(root?.yes), + no: this._normalizeBookSide(root?.no) + }; + } + + _bestBookPrice(sideBook) { + if (!Array.isArray(sideBook) || !sideBook.length) return null; + return this._num(sideBook[0][0]); + } + _pickBestMarket(markets = []) { const now = Date.now(); @@ -165,6 +250,7 @@ export class MarketTracker extends EventEmitter { } const newTicker = selectedMarket.ticker; + if (newTicker === this.currentTicker) { this.currentEvent = selectedEvent.event_ticker || this.currentEvent; this.marketData = { ...(this.marketData || {}), ...selectedMarket }; @@ -174,7 +260,6 @@ export class MarketTracker extends EventEmitter { const oldTicker = this.currentTicker; - // Unsubscribe from old if (oldTicker) { console.log(`[Tracker] Rotating from ${oldTicker} → ${newTicker}`); this.ws.unsubscribeTicker(oldTicker); @@ -185,17 +270,23 @@ export class MarketTracker extends EventEmitter { this.marketData = selectedMarket; this.orderbook = { yes: [], no: [] }; - // Fetch fresh orderbook via REST try { - const ob = await getOrderbook(newTicker); - this.orderbook = ob; + const [freshMarket, ob] = await Promise.all([ + getMarket(newTicker).catch(() => null), + getOrderbook(newTicker).catch(() => null) + ]); + + if (freshMarket) this.marketData = { ...selectedMarket, ...freshMarket }; + if (ob) this.orderbook = this._normalizeOrderbook(ob); } catch (e) { - console.error('[Tracker] Orderbook fetch error:', e.message); + console.error('[Tracker] Initial market bootstrap error:', e.message); } - // Subscribe via WS this.ws.subscribeTicker(newTicker); - console.log(`[Tracker] Now tracking: ${newTicker} (${selectedMarket.title || selectedMarket.subtitle || selectedEvent.event_ticker})`); + + console.log( + `[Tracker] Now tracking: ${newTicker} (${this.marketData?.title || this.marketData?.subtitle || selectedEvent.event_ticker})` + ); this.emit('update', this.getState()); this.emit('market-rotated', { from: oldTicker, to: newTicker }); @@ -205,11 +296,10 @@ export class MarketTracker extends EventEmitter { } async _checkRotation() { - // Refresh market data via REST if (this.currentTicker) { try { const fresh = await getMarket(this.currentTicker); - this.marketData = fresh; + this.marketData = { ...(this.marketData || {}), ...(fresh || {}) }; const state = this.getState(); this.emit('update', state); @@ -217,8 +307,7 @@ export class MarketTracker extends EventEmitter { const status = String(fresh?.status || '').toLowerCase(); const settledLike = status === 'closed' || status === 'settled' || status === 'expired' || status === 'finalized'; - // If market closed/settled, find the next one - if (settledLike || fresh.result) { + if (settledLike || fresh?.result) { console.log(`[Tracker] Market ${this.currentTicker} settled (result: ${fresh.result}). Rotating...`); this.emit('settled', { ticker: this.currentTicker, result: fresh.result }); this.currentTicker = null; @@ -236,11 +325,28 @@ export class MarketTracker extends EventEmitter { if (msg.market_ticker !== this.currentTicker) return; if (msg.type === 'orderbook_snapshot') { - this.orderbook = { yes: msg.yes || [], no: msg.no || [] }; + this.orderbook = this._normalizeOrderbook(msg); } else if (msg.type === 'orderbook_delta') { - // Apply delta updates - if (msg.yes) this.orderbook.yes = this._applyDelta(this.orderbook.yes, msg.yes); - if (msg.no) this.orderbook.no = this._applyDelta(this.orderbook.no, msg.no); + const side = String(msg.side || '').toLowerCase(); + const price = this._num(msg.price); + const delta = this._num(msg.delta); + const absoluteQty = this._num(msg.qty ?? msg.quantity ?? msg.size); + + if ((side === 'yes' || side === 'no') && price != null) { + const book = this.orderbook[side] || []; + const map = new Map(book); + + const current = this._num(map.get(price)) ?? 0; + const next = delta != null ? current + delta : (absoluteQty ?? current); + + if (next <= 0) map.delete(price); + else map.set(price, next); + + this.orderbook[side] = [...map.entries()].sort((a, b) => b[0] - a[0]); + } else { + if (Array.isArray(msg.yes)) this.orderbook.yes = this._applyDelta(this.orderbook.yes, msg.yes); + if (Array.isArray(msg.no)) this.orderbook.no = this._applyDelta(this.orderbook.no, msg.no); + } } this.emit('update', this.getState()); @@ -249,15 +355,21 @@ export class MarketTracker extends EventEmitter { _onTicker(msg) { if (msg.market_ticker !== this.currentTicker) return; - // Merge ticker data into marketData if (this.marketData) { + const yesBid = this._num(msg.yes_bid); + const yesAsk = this._num(msg.yes_ask); + const noBid = this._num(msg.no_bid); + const noAsk = this._num(msg.no_ask); + const lastPrice = this._num(msg.last_price); + const volume = this._num(msg.volume); + Object.assign(this.marketData, { - yes_bid: msg.yes_bid ?? this.marketData.yes_bid, - yes_ask: msg.yes_ask ?? this.marketData.yes_ask, - no_bid: msg.no_bid ?? this.marketData.no_bid, - no_ask: msg.no_ask ?? this.marketData.no_ask, - last_price: msg.last_price ?? this.marketData.last_price, - volume: msg.volume ?? this.marketData.volume + yes_bid: yesBid ?? this.marketData.yes_bid, + yes_ask: yesAsk ?? this.marketData.yes_ask, + no_bid: noBid ?? this.marketData.no_bid, + no_ask: noAsk ?? this.marketData.no_ask, + last_price: lastPrice ?? this.marketData.last_price, + volume: volume ?? this.marketData.volume }); } @@ -265,11 +377,28 @@ export class MarketTracker extends EventEmitter { } _applyDelta(book, deltas) { - const map = new Map(book); - for (const [price, qty] of deltas) { - if (qty === 0) map.delete(price); - else map.set(price, qty); + const map = new Map(book || []); + + for (const delta of Array.isArray(deltas) ? deltas : []) { + let price = null; + let qty = null; + + if (Array.isArray(delta)) { + price = delta[0]; + qty = delta[1]; + } else if (delta && typeof delta === 'object') { + price = delta.price ?? delta[0]; + qty = delta.qty ?? delta.quantity ?? delta.size ?? delta[1]; + } + + const p = this._num(price); + const q = this._num(qty); + if (p == null || q == null) continue; + + if (q <= 0) map.delete(p); + else map.set(p, q); } - return [...map.entries()].sort((a, b) => a[0] - b[0]); + + return [...map.entries()].sort((a, b) => b[0] - a[0]); } }