mirror of
https://github.com/multipleof4/KalBot.git
synced 2026-03-16 21:41:02 +00:00
212 lines
6.7 KiB
JavaScript
212 lines
6.7 KiB
JavaScript
import { getActiveBTCEvent, 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']);
|
|
|
|
/**
|
|
* 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) {
|
|
if (!this.currentTicker) this.emit('update', null);
|
|
console.log('[Tracker] No active BTC 15m event found. Retrying in 30s...');
|
|
return;
|
|
}
|
|
|
|
const inlineMarkets = Array.isArray(event.markets) ? event.markets : [];
|
|
const markets = inlineMarkets.length ? inlineMarkets : await getEventMarkets(event.event_ticker);
|
|
|
|
const market = markets.find((m) => OPEN_MARKET_STATUSES.has(String(m?.status || '').toLowerCase())) || markets[0];
|
|
|
|
if (!market) {
|
|
if (!this.currentTicker) {
|
|
this.currentEvent = event.event_ticker || null;
|
|
this.marketData = null;
|
|
this.orderbook = { yes: [], no: [] };
|
|
this.emit('update', null);
|
|
}
|
|
console.log(`[Tracker] Event ${event.event_ticker} has no active market yet. Retrying...`);
|
|
return;
|
|
}
|
|
|
|
const newTicker = market.ticker;
|
|
if (newTicker === this.currentTicker) {
|
|
this.currentEvent = event.event_ticker;
|
|
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 = 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: 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);
|
|
|
|
// 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]);
|
|
}
|
|
}
|