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']); /** * Converts a dollar string like "0.4200" to cents integer (42). * Returns null if not parseable. */ function dollarsToCents(val) { if (val == null) return null; const n = Number(val); if (!Number.isFinite(n)) return null; return Math.round(n * 100); } /** * 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...'); this.ws.connect(); this.ws.on('orderbook', (msg) => this._onOrderbook(msg)); this.ws.on('ticker', (msg) => this._onTicker(msg)); await this._findAndSubscribe(); this.rotateInterval = setInterval(() => this._checkRotation(), 30000); } stop() { clearInterval(this.rotateInterval); this.ws.disconnect(); } getState() { if (!this.marketData) return null; const quotes = this._extractMarketQuotes(this.marketData); const bestYesBook = this._bestBookPrice(this.orderbook.yes); const bestNoBook = this._bestBookPrice(this.orderbook.no); 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; 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._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, timestamp: Date.now(), // Expose raw orderbook for strategies and live engine orderbook: { yes: this.orderbook.yes, no: this.orderbook.no } }; } _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; }; let yesBid = pick('yes_bid', 'yesBid'); let yesAsk = pick('yes_ask', 'yesAsk'); let noBid = pick('no_bid', 'noBid'); let noAsk = pick('no_ask', 'noAsk'); let lastPrice = pick('last_price', 'lastPrice', 'yes_price', 'yesPrice'); if (yesBid == null) yesBid = dollarsToCents(market?.yes_bid_dollars); if (yesAsk == null) yesAsk = dollarsToCents(market?.yes_ask_dollars); if (noBid == null) noBid = dollarsToCents(market?.no_bid_dollars); if (noAsk == null) noAsk = dollarsToCents(market?.no_ask_dollars); if (lastPrice == null) lastPrice = dollarsToCents(market?.price_dollars); return { yesBid, yesAsk, noBid, noAsk, lastPrice }; } _normalizeBookSide(levels) { if (!Array.isArray(levels)) return []; const out = []; for (const level of levels) { let price = null; let qty = null; if (Array.isArray(level)) { const rawPrice = level[0]; const rawQty = level[1]; if (typeof rawPrice === 'string' && rawPrice.includes('.')) { price = dollarsToCents(rawPrice); qty = this._num(rawQty); } else { price = this._num(rawPrice); qty = this._num(rawQty); } } else if (level && typeof level === 'object') { const rawPrice = level.price ?? level.price_dollars ?? level[0]; const rawQty = level.qty ?? level.quantity ?? level.size ?? level.count ?? level[1]; if (typeof rawPrice === 'string' && rawPrice.includes('.')) { price = dollarsToCents(rawPrice); } else { price = this._num(rawPrice); } qty = this._num(rawQty); } if (price == null || qty == null || qty <= 0) continue; out.push([price, qty]); } 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 ?? root?.yes_dollars_fp ?? root?.yes_dollars), no: this._normalizeBookSide(root?.no ?? root?.no_dollars_fp ?? root?.no_dollars) }; } _bestBookPrice(sideBook) { if (!Array.isArray(sideBook) || !sideBook.length) return null; return this._num(sideBook[0][0]); } _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; 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: [] }; try { 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] Initial market bootstrap error:', e.message); } this.ws.subscribeTicker(newTicker); 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 }); } catch (err) { console.error('[Tracker] Discovery error:', err.message); } } async _checkRotation() { if (this.currentTicker) { try { const fresh = await getMarket(this.currentTicker); this.marketData = { ...(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 (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 = this._normalizeOrderbook(msg); console.log(`[Tracker] Orderbook snapshot: ${this.orderbook.yes.length} yes levels, ${this.orderbook.no.length} no levels`); } else if (msg.type === 'orderbook_delta') { const side = String(msg.side || '').toLowerCase(); let price = this._num(msg.price); if (price == null) price = dollarsToCents(msg.price_dollars); let delta = this._num(msg.delta); if (delta == null) delta = this._num(msg.delta_fp); 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 { const yesArr = msg.yes ?? msg.yes_dollars_fp; const noArr = msg.no ?? msg.no_dollars_fp; if (Array.isArray(yesArr)) this.orderbook.yes = this._applyDelta(this.orderbook.yes, yesArr); if (Array.isArray(noArr)) this.orderbook.no = this._applyDelta(this.orderbook.no, noArr); } } this.emit('update', this.getState()); } _onTicker(msg) { if (msg.market_ticker !== this.currentTicker) return; if (this.marketData) { const fields = [ 'yes_bid', 'yes_ask', 'no_bid', 'no_ask', 'last_price', 'volume', 'yes_bid_dollars', 'yes_ask_dollars', 'no_bid_dollars', 'no_ask_dollars', 'price_dollars', 'volume_fp', 'open_interest_fp', 'dollar_volume', 'dollar_open_interest' ]; for (const key of fields) { if (msg[key] != null) this.marketData[key] = msg[key]; } if (msg.dollar_volume != null) this.marketData.volume = this._num(msg.dollar_volume) ?? this.marketData.volume; if (msg.dollar_open_interest != null) this.marketData.open_interest = this._num(msg.dollar_open_interest) ?? this.marketData.open_interest; if (msg.volume_fp != null && this.marketData.volume == null) this.marketData.volume = this._num(msg.volume_fp); } this.emit('update', this.getState()); } _applyDelta(book, deltas) { const map = new Map(book || []); for (const delta of Array.isArray(deltas) ? deltas : []) { let price = null; let qty = null; if (Array.isArray(delta)) { const rawPrice = delta[0]; const rawQty = delta[1]; if (typeof rawPrice === 'string' && rawPrice.includes('.')) { price = dollarsToCents(rawPrice); } else { price = this._num(rawPrice); } qty = this._num(rawQty); } else if (delta && typeof delta === 'object') { const rawPrice = delta.price ?? delta.price_dollars ?? delta[0]; const rawQty = delta.qty ?? delta.quantity ?? delta.size ?? delta[1]; if (typeof rawPrice === 'string' && rawPrice.includes('.')) { price = dollarsToCents(rawPrice); } else { price = this._num(rawPrice); } qty = this._num(rawQty); } if (price == null || qty == null) continue; if (qty <= 0) map.delete(price); else map.set(price, qty); } return [...map.entries()].sort((a, b) => b[0] - a[0]); } }