From d1683eaa113d6ec5cbed389888cf6fefa7c15ce2 Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Sun, 15 Mar 2026 13:08:36 -0700 Subject: [PATCH] Feat: Market tracker auto-rotates BTC 15m markets --- lib/market/tracker.js | 197 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 lib/market/tracker.js diff --git a/lib/market/tracker.js b/lib/market/tracker.js new file mode 100644 index 0000000..75e487d --- /dev/null +++ b/lib/market/tracker.js @@ -0,0 +1,197 @@ +import { getActiveBTCEvent, getEventMarkets, getOrderbook, getMarket } from '../kalshi/rest.js'; +import { KalshiWS } from '../kalshi/websocket.js'; +import { EventEmitter } from 'events'; + +/** + * Tracks the currently active BTC 15-min market. + * Auto-rotates when the current market expires. + * Emits 'update' with full market state on every change. + */ +export class MarketTracker extends EventEmitter { + constructor() { + super(); + this.ws = new KalshiWS(); + this.currentTicker = null; + this.currentEvent = null; + this.marketData = null; + this.orderbook = { yes: [], no: [] }; + this.rotateInterval = null; + } + + 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); + } + + stop() { + clearInterval(this.rotateInterval); + this.ws.disconnect(); + } + + 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; + + // Prices on Kalshi are in cents (1-99) + const yesPct = yesAsk || 50; + const noPct = noAsk || 50; + + // Odds = 100 / price + const yesOdds = yesPct > 0 ? (100 / yesPct).toFixed(2) : '0.00'; + const noOdds = noPct > 0 ? (100 / noPct).toFixed(2) : '0.00'; + + return { + ticker: this.currentTicker, + eventTicker: this.currentEvent, + title: this.marketData.title || 'BTC Up or Down - 15 min', + subtitle: this.marketData.subtitle || '', + yesPct, + 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, + closeTime: this.marketData.close_time || this.marketData.expiration_time, + status: this.marketData.status, + result: this.marketData.result, + timestamp: Date.now() + }; + } + + async _findAndSubscribe() { + try { + const event = await getActiveBTCEvent(); + if (!event) { + console.log('[Tracker] No active BTC 15m event found. Retrying in 30s...'); + return; + } + + const markets = event.markets || await getEventMarkets(event.event_ticker); + // Find the up/down market (usually only one market per event) + const market = markets.find(m => m.status === 'active' || m.status === 'open') || markets[0]; + + if (!market) { + console.log('[Tracker] No active market in event. Retrying...'); + return; + } + + const newTicker = market.ticker; + + if (newTicker === this.currentTicker) return; + + // Unsubscribe from old + if (this.currentTicker) { + console.log(`[Tracker] Rotating from ${this.currentTicker} → ${newTicker}`); + this.ws.unsubscribeTicker(this.currentTicker); + } + + this.currentTicker = newTicker; + this.currentEvent = event.event_ticker; + this.marketData = market; + this.orderbook = { yes: [], no: [] }; + + // Fetch fresh orderbook via REST + try { + const ob = await getOrderbook(newTicker); + this.orderbook = ob; + } catch (e) { + console.error('[Tracker] Orderbook fetch error:', e.message); + } + + // Subscribe via WS + this.ws.subscribeTicker(newTicker); + console.log(`[Tracker] Now tracking: ${newTicker} (${market.title || market.subtitle})`); + + this.emit('update', this.getState()); + this.emit('market-rotated', { from: this.currentTicker, to: newTicker }); + + } catch (err) { + console.error('[Tracker] Discovery error:', err.message); + } + } + + async _checkRotation() { + // Refresh market data via REST + if (this.currentTicker) { + try { + const fresh = await getMarket(this.currentTicker); + this.marketData = fresh; + + const state = this.getState(); + this.emit('update', state); + + // If market closed/settled, find the next one + if (fresh.status === 'closed' || fresh.status === 'settled' || 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; + await this._findAndSubscribe(); + } + } catch (e) { + console.error('[Tracker] Refresh error:', e.message); + } + } else { + await this._findAndSubscribe(); + } + } + + _onOrderbook(msg) { + if (msg.market_ticker !== this.currentTicker) return; + + if (msg.type === 'orderbook_snapshot') { + this.orderbook = { yes: msg.yes || [], no: msg.no || [] }; + } 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); + } + + this.emit('update', this.getState()); + } + + _onTicker(msg) { + if (msg.market_ticker !== this.currentTicker) return; + + // Merge ticker data into marketData + if (this.marketData) { + 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 + }); + } + + this.emit('update', this.getState()); + } + + _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); + } + return [...map.entries()].sort((a, b) => a[0] - b[0]); + } +}