mirror of
https://github.com/multipleof4/KalBot.git
synced 2026-03-17 05:51:02 +00:00
246 lines
7.3 KiB
JavaScript
246 lines
7.3 KiB
JavaScript
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<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();
|
|
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 };
|