mirror of
https://github.com/multipleof4/KalBot.git
synced 2026-03-16 21:41:02 +00:00
454 lines
15 KiB
JavaScript
454 lines
15 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']);
|
|
|
|
/**
|
|
* 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]);
|
|
}
|
|
}
|