Fix: reduce Kalshi 400/429 spam

This commit is contained in:
2026-03-15 15:34:14 -07:00
parent e93381c9f1
commit aedb6aeda5

View File

@@ -1,20 +1,80 @@
import { signRequest, KALSHI_API_BASE } from './auth.js'; 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 OPEN_EVENT_STATUSES = new Set(['open', 'active', 'initialized', 'trading']);
const TRADABLE_EVENT_STATUSES = new Set(['open', 'active', 'trading']); const TRADABLE_EVENT_STATUSES = new Set(['open', 'active', 'trading']);
async function kalshiFetch(method, path, body = null) { const DEFAULT_HTTP_RETRIES = Math.max(0, Number(process.env.KALSHI_HTTP_RETRIES || 3));
const headers = signRequest(method, path); const BASE_BACKOFF_MS = Math.max(100, Number(process.env.KALSHI_HTTP_BACKOFF_MS || 350));
const opts = { method, headers };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${KALSHI_API_BASE}${path}`, opts); const EVENTS_CACHE_TTL_MS = Math.max(1000, Number(process.env.KALSHI_EVENTS_CACHE_TTL_MS || 5000));
if (!res.ok) { const eventsCache = new Map(); // key -> { expiresAt, data }
const inflightEvents = new Map(); // key -> Promise<events>
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(); 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}${res.status}: ${text}`);
} }
return res.json();
throw new Error(`Kalshi API ${method} ${path} failed after retries`);
} }
function getTimeMs(value) { function getTimeMs(value) {
@@ -46,11 +106,10 @@ function rankEvents(events = []) {
const tradableLike = TRADABLE_EVENT_STATUSES.has(status); const tradableLike = TRADABLE_EVENT_STATUSES.has(status);
const notClearlyExpired = closeTs == null || closeTs > now - 60_000; 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 delta = closeTs == null ? Number.MAX_SAFE_INTEGER : closeTs - now;
const closenessScore = delta < 0 ? Math.abs(delta) + 3_600_000 : delta; 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) .filter((x) => x.openLike || x.notClearlyExpired)
.sort((a, b) => { .sort((a, b) => {
@@ -62,35 +121,75 @@ function rankEvents(events = []) {
.map((x) => x.event); .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) { async function fetchEvents(series, query) {
const path = `/trade-api/v2/events?series_ticker=${encodeURIComponent(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 data = await kalshiFetch('GET', path);
return Array.isArray(data.events) ? data.events : []; 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. * Return ranked candidate events for BTC 15m.
*/ */
export async function getActiveBTCEvents(limit = 12) { export async function getActiveBTCEvents(limit = 12) {
const seriesCandidates = [...new Set([SERIES_TICKER, SERIES_TICKER.toLowerCase()])]; const seriesCandidates = [SERIES_TICKER];
const eventMap = new Map(); const eventMap = new Map();
for (const series of seriesCandidates) { for (const series of seriesCandidates) {
for (const query of [
'status=open&limit=10',
'status=active&limit=10',
'status=initialized&limit=10',
'limit=50'
]) {
try { try {
const events = await fetchEvents(series, query); // Use only known-good filter to avoid 400s from unsupported statuses.
for (const event of events) { const openEvents = await fetchEvents(series, 'status=open&limit=25');
for (const event of openEvents) {
if (event?.event_ticker) eventMap.set(event.event_ticker, event); if (event?.event_ticker) eventMap.set(event.event_ticker, event);
} }
} catch (e) {
console.error(`[Kalshi] Event fetch failed (${series}, ${query}):`, e.message); // 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); return rankEvents([...eventMap.values()]).slice(0, limit);