import { signRequest, KALSHI_API_BASE } from './auth.js'; const SERIES_TICKER = (process.env.KALSHI_SERIES_TICKER || 'KXBTC15M').trim().toUpperCase(); const OPEN_EVENT_STATUSES = new Set(['open', 'active', 'initialized', 'trading']); const TRADABLE_EVENT_STATUSES = new Set(['open', 'active', 'trading']); const DEFAULT_HTTP_RETRIES = Math.max(0, Number(process.env.KALSHI_HTTP_RETRIES || 3)); const BASE_BACKOFF_MS = Math.max(100, Number(process.env.KALSHI_HTTP_BACKOFF_MS || 350)); const EVENTS_CACHE_TTL_MS = Math.max(1000, Number(process.env.KALSHI_EVENTS_CACHE_TTL_MS || 5000)); const eventsCache = new Map(); // key -> { expiresAt, data } const inflightEvents = new Map(); // key -> Promise const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function parseRetryAfterMs(value) { if (!value) return null; const asSeconds = Number(value); if (Number.isFinite(asSeconds) && asSeconds >= 0) return asSeconds * 1000; const asDate = new Date(value).getTime(); if (Number.isFinite(asDate)) return Math.max(0, asDate - Date.now()); return null; } function backoffMs(attempt) { const exp = BASE_BACKOFF_MS * Math.pow(2, attempt); const jitter = Math.floor(Math.random() * 200); return exp + jitter; } async function kalshiFetch(method, path, body = null, opts = {}) { const retries = Number.isFinite(opts.retries) ? opts.retries : DEFAULT_HTTP_RETRIES; const payload = body == null ? null : JSON.stringify(body); for (let attempt = 0; attempt <= retries; attempt++) { const headers = signRequest(method, path); const req = { method, headers }; if (payload) req.body = payload; let res; try { res = await fetch(`${KALSHI_API_BASE}${path}`, req); } catch (e) { if (attempt < retries) { await sleep(backoffMs(attempt)); continue; } throw new Error(`Kalshi API ${method} ${path} network error: ${e.message}`); } if (res.ok) { if (res.status === 204) return {}; const text = await res.text(); if (!text) return {}; try { return JSON.parse(text); } catch { return {}; } } const text = await res.text(); const retryable = res.status === 429 || (res.status >= 500 && res.status < 600); if (retryable && attempt < retries) { const retryAfter = parseRetryAfterMs(res.headers.get('retry-after')); await sleep(retryAfter ?? backoffMs(attempt)); continue; } throw new Error(`Kalshi API ${method} ${path} → ${res.status}: ${text}`); } throw new Error(`Kalshi API ${method} ${path} failed after retries`); } function getTimeMs(value) { if (!value) return null; const ts = new Date(value).getTime(); return Number.isFinite(ts) ? ts : null; } function getEventCloseTimeMs(event) { return ( getTimeMs(event?.close_time) || getTimeMs(event?.expiration_time) || getTimeMs(event?.settlement_time) || getTimeMs(event?.end_date) || null ); } function rankEvents(events = []) { const now = Date.now(); return events .filter(Boolean) .map((event) => { const status = String(event.status || '').toLowerCase(); const closeTs = getEventCloseTimeMs(event); const openLike = OPEN_EVENT_STATUSES.has(status); const tradableLike = TRADABLE_EVENT_STATUSES.has(status); const notClearlyExpired = closeTs == null || closeTs > now - 60_000; const delta = closeTs == null ? Number.MAX_SAFE_INTEGER : closeTs - now; const closenessScore = delta < 0 ? Math.abs(delta) + 3_600_000 : delta; return { event, openLike, tradableLike, notClearlyExpired, closenessScore }; }) .filter((x) => x.openLike || x.notClearlyExpired) .sort((a, b) => { if (a.tradableLike !== b.tradableLike) return a.tradableLike ? -1 : 1; if (a.openLike !== b.openLike) return a.openLike ? -1 : 1; if (a.notClearlyExpired !== b.notClearlyExpired) return a.notClearlyExpired ? -1 : 1; return a.closenessScore - b.closenessScore; }) .map((x) => x.event); } function getCachedEvents(key) { const hit = eventsCache.get(key); if (!hit) return null; if (Date.now() > hit.expiresAt) { eventsCache.delete(key); return null; } return hit.data; } function setCachedEvents(key, events) { eventsCache.set(key, { expiresAt: Date.now() + EVENTS_CACHE_TTL_MS, data: events }); } async function fetchEvents(series, query) { const normalizedSeries = String(series || '').trim().toUpperCase(); const normalizedQuery = String(query || '').trim(); const key = `${normalizedSeries}|${normalizedQuery}`; const cached = getCachedEvents(key); if (cached) return cached; const pending = inflightEvents.get(key); if (pending) return pending; const task = (async () => { try { const path = `/trade-api/v2/events?series_ticker=${encodeURIComponent(normalizedSeries)}&${normalizedQuery}`; const data = await kalshiFetch('GET', path); const events = Array.isArray(data.events) ? data.events : []; setCachedEvents(key, events); return events; } finally { inflightEvents.delete(key); } })(); inflightEvents.set(key, task); return task; } /** * Return ranked candidate events for BTC 15m. */ export async function getActiveBTCEvents(limit = 12) { const seriesCandidates = [SERIES_TICKER]; const eventMap = new Map(); for (const series of seriesCandidates) { try { // Use only known-good filter to avoid 400s from unsupported statuses. const openEvents = await fetchEvents(series, 'status=open&limit=25'); for (const event of openEvents) { if (event?.event_ticker) eventMap.set(event.event_ticker, event); } // Fallback if endpoint returns empty. if (!openEvents.length) { const fallbackEvents = await fetchEvents(series, 'limit=25'); for (const event of fallbackEvents) { if (event?.event_ticker) eventMap.set(event.event_ticker, event); } } } catch (e) { console.error(`[Kalshi] Event fetch failed (${series}):`, e.message); } } return rankEvents([...eventMap.values()]).slice(0, limit); } /** * Backward-compatible: return single best candidate event. */ export async function getActiveBTCEvent() { const events = await getActiveBTCEvents(1); return events[0] || null; } /** * Get markets for a specific event ticker. */ export async function getEventMarkets(eventTicker) { const data = await kalshiFetch('GET', `/trade-api/v2/events/${eventTicker}`); const markets = data?.event?.markets ?? data?.markets ?? data?.event_markets ?? []; return Array.isArray(markets) ? markets : []; } /** * Get orderbook for a specific market ticker. */ export async function getOrderbook(ticker) { const data = await kalshiFetch('GET', `/trade-api/v2/markets/${ticker}/orderbook`); return data.orderbook || data; } /** * Get single market details. */ export async function getMarket(ticker) { const data = await kalshiFetch('GET', `/trade-api/v2/markets/${ticker}`); return data.market || data; } /** * Place a real order on Kalshi. NOT used in paper mode. */ export async function placeOrder(params) { return kalshiFetch('POST', '/trade-api/v2/portfolio/orders', params); } /** * Get wallet balance. */ export async function getBalance() { return kalshiFetch('GET', '/trade-api/v2/portfolio/balance'); } export { kalshiFetch };