import { getActiveBTCEvents, getEventMarkets, getOrderbook, getMarket } from '../kalshi/rest.js'; import { KalshiWS } from '../kalshi/websocket.js'; import { EventEmitter } from 'events'; const OPEN_MARKET_STATUSES = new Set(['open', 'active', 'initialized', 'trading']); const TRADABLE_MARKET_STATUSES = new Set(['open', 'active', 'trading']); /** * 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() }; } _toTs(value) { if (!value) return null; const ts = new Date(value).getTime(); return Number.isFinite(ts) ? ts : null; } _pickBestMarket(markets = []) { const now = Date.now(); const ranked = markets .filter(Boolean) .map((market) => { const status = String(market?.status || '').toLowerCase(); const closeTs = this._toTs(market?.close_time) || this._toTs(market?.expiration_time) || this._toTs(market?.settlement_time) || null; const tradable = TRADABLE_MARKET_STATUSES.has(status); const openLike = OPEN_MARKET_STATUSES.has(status); const notClearlyExpired = closeTs == null || closeTs > now - 60_000; return { market, tradable, openLike, notClearlyExpired, closeTs }; }) .filter((x) => x.openLike || x.notClearlyExpired); if (!ranked.length) return markets[0] || null; ranked.sort((a, b) => { if (a.tradable !== b.tradable) return a.tradable ? -1 : 1; if (a.openLike !== b.openLike) return a.openLike ? -1 : 1; if (a.notClearlyExpired !== b.notClearlyExpired) return a.notClearlyExpired ? -1 : 1; const aTs = a.closeTs ?? Number.MAX_SAFE_INTEGER; const bTs = b.closeTs ?? Number.MAX_SAFE_INTEGER; return aTs - bTs; }); return ranked[0].market; } async _findAndSubscribe() { try { const candidates = await getActiveBTCEvents(12); if (!candidates.length) { if (!this.currentTicker) this.emit('update', null); console.log('[Tracker] No active BTC 15m event found. Retrying in 30s...'); return; } let selectedEvent = null; let selectedMarket = null; for (const event of candidates) { const eventTicker = event?.event_ticker; if (!eventTicker) continue; let markets = Array.isArray(event.markets) ? event.markets : []; if (!markets.length) { try { markets = await getEventMarkets(eventTicker); } catch (e) { console.error(`[Tracker] Failed loading markets for ${eventTicker}:`, e.message); continue; } } if (!markets.length) continue; const market = this._pickBestMarket(markets); if (!market?.ticker) continue; selectedEvent = event; selectedMarket = market; break; } if (!selectedEvent || !selectedMarket) { if (!this.currentTicker) this.emit('update', null); console.log('[Tracker] No tradable BTC 15m market found yet. Retrying...'); return; } const newTicker = selectedMarket.ticker; if (newTicker === this.currentTicker) { this.currentEvent = selectedEvent.event_ticker || this.currentEvent; this.marketData = { ...(this.marketData || {}), ...selectedMarket }; this.emit('update', this.getState()); return; } const oldTicker = this.currentTicker; // Unsubscribe from old if (oldTicker) { console.log(`[Tracker] Rotating from ${oldTicker} → ${newTicker}`); this.ws.unsubscribeTicker(oldTicker); } this.currentTicker = newTicker; this.currentEvent = selectedEvent.event_ticker; this.marketData = selectedMarket; 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} (${selectedMarket.title || selectedMarket.subtitle || selectedEvent.event_ticker})`); this.emit('update', this.getState()); this.emit('market-rotated', { from: oldTicker, 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); 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) { 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]); } }