diff --git a/lib/kalshi/rest.js b/lib/kalshi/rest.js index 6681155..40460f6 100644 --- a/lib/kalshi/rest.js +++ b/lib/kalshi/rest.js @@ -1,20 +1,80 @@ import { signRequest, KALSHI_API_BASE } from './auth.js'; -const SERIES_TICKER = 'KXBTC15M'; +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']); -async function kalshiFetch(method, path, body = null) { - const headers = signRequest(method, path); - const opts = { method, headers }; - if (body) opts.body = JSON.stringify(body); +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 res = await fetch(`${KALSHI_API_BASE}${path}`, opts); - if (!res.ok) { 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}`); } - return res.json(); + + throw new Error(`Kalshi API ${method} ${path} failed after retries`); } function getTimeMs(value) { @@ -46,11 +106,10 @@ function rankEvents(events = []) { const tradableLike = TRADABLE_EVENT_STATUSES.has(status); const notClearlyExpired = closeTs == null || closeTs > now - 60_000; - // Prefer near-future close times; heavily penalize stale/past 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, closeTs }; + return { event, openLike, tradableLike, notClearlyExpired, closenessScore }; }) .filter((x) => x.openLike || x.notClearlyExpired) .sort((a, b) => { @@ -62,34 +121,74 @@ function rankEvents(events = []) { .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 path = `/trade-api/v2/events?series_ticker=${encodeURIComponent(series)}&${query}`; - const data = await kalshiFetch('GET', path); - return Array.isArray(data.events) ? data.events : []; + 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 = [...new Set([SERIES_TICKER, SERIES_TICKER.toLowerCase()])]; + const seriesCandidates = [SERIES_TICKER]; const eventMap = new Map(); for (const series of seriesCandidates) { - for (const query of [ - 'status=open&limit=10', - 'status=active&limit=10', - 'status=initialized&limit=10', - 'limit=50' - ]) { - try { - const events = await fetchEvents(series, query); - for (const event of events) { + 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}, ${query}):`, e.message); } + } catch (e) { + console.error(`[Kalshi] Event fetch failed (${series}):`, e.message); } }