Files
KalBot/lib/market/tracker.js

276 lines
8.7 KiB
JavaScript

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]);
}
}