ccxt
Version:
987 lines (984 loc) • 131 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var sha2_js = require('@noble/hashes/sha2.js');
var kalshi$1 = require('../abstract/prediction/kalshi.js');
var Precise = require('../base/Precise.js');
var rsa = require('../base/functions/rsa.js');
var errors = require('../base/errors.js');
// ----------------------------------------------------------------------------
// ---------------------------------------------------------------------------
/**
* @class kalshi
* @augments Exchange
*/
class kalshi extends kalshi$1["default"] {
describe() {
return this.deepExtend(super.describe(), {
'id': 'kalshi',
'name': 'Kalshi',
'countries': ['US'],
'rateLimit': 200,
'certified': false,
'pro': false,
'has': {
'CORS': undefined,
'spot': false,
'margin': false,
'swap': false,
'future': false,
'option': false,
'cancelAllOrders': true,
'cancelOrder': true,
'createOrder': true,
'editOrder': true,
'fetchBalance': true,
'fetchClosedOrders': true,
'fetchCurrencies': false,
'fetchEvent': true,
'fetchEvents': true,
'fetchMarkets': true,
'fetchMyTrades': true,
'fetchOHLCV': true,
'fetchOpenInterest': true,
'fetchOpenOrders': true,
'fetchOrder': true,
'fetchOrderBook': true,
'fetchOrders': true,
'fetchPositions': true,
'fetchSettlements': true,
'fetchStatus': true,
'fetchTicker': true,
'fetchTickers': true,
'fetchTrades': true,
'prediction': true,
},
'timeframes': {
// kalshi's candlesticks period_interval accepts ONLY 1 (minute), 60 (hour) or
// 1440 (day) — advertising 5m/15m/6h would 400 at the API
'1m': 1,
'1h': 60,
'1d': 1440,
},
'urls': {
'logo': 'https://github.com/user-attachments/assets/74fc2acb-58d0-4db0-b316-3124e7dc24db',
'api': {
'kalshi': 'https://external-api.kalshi.com/trade-api/v2',
// free-text search (/v1/search/series) lives only on the elections web host —
// external-api returns 404 for it. discovery-only, read-only, no auth.
'elections': 'https://api.elections.kalshi.com/v1',
},
'test': {
'kalshi': 'https://external-api.demo.kalshi.co/trade-api/v2',
// demo has no synthetic search index; point discovery at the live elections host
'elections': 'https://api.elections.kalshi.com/v1',
},
'www': 'https://kalshi.com',
'doc': ['https://trading-api.readme.io/reference/getting-started'],
},
'api': {
'kalshi': {
'public': {
'get': {
'events': 1,
'events/multivariate': 1,
'events/fee_changes': 1,
'events/{event_ticker}': 1,
'events/{event_ticker}/metadata': 1,
'series': 1,
'series/fee_changes': 1,
'series/{series_ticker}': 1,
'series/{series_ticker}/markets/{ticker}/candlesticks': 1,
'series/{series_ticker}/events/{ticker}/candlesticks': 1,
'series/{series_ticker}/events/{ticker}/forecast_percentile_history': 1,
'markets': 1,
'markets/trades': 1,
'markets/orderbooks': 1,
'markets/candlesticks': 1,
'markets/{ticker}': 1,
'markets/{ticker}/orderbook': 1,
'exchange/status': 1,
'exchange/schedule': 1,
'exchange/announcements': 1,
'exchange/user_data_timestamp': 1,
'milestones': 1,
'milestones/{milestone_id}': 1,
'structured_targets': 1,
'structured_targets/{structured_target_id}': 1,
'search/filters_by_sport': 1,
'search/tags_by_categories': 1,
'live_data/batch': 1,
'live_data/milestone/{milestone_id}': 1,
'historical/markets': 1,
'historical/markets/{ticker}/candlesticks': 1,
'historical/trades': 1,
'historical/cutoff_timestamps': 1,
'multivariate_event_collections': 1,
'multivariate_event_collections/{collection_ticker}': 1,
'multivariate_event_collections/{collection_ticker}/lookup': 1,
'incentive_programs': 1,
},
},
'private': {
'get': {
'portfolio/balance': 1,
'portfolio/orders': 1,
'portfolio/orders/{order_id}': 1,
'portfolio/orders/{order_id}/queue_position': 1,
'portfolio/orders/queue_positions': 1,
'portfolio/positions': 1,
'portfolio/fills': 1,
'portfolio/settlements': 1,
'portfolio/deposits': 1,
'portfolio/withdrawals': 1,
'portfolio/order_groups': 1,
'portfolio/order_groups/{order_group_id}': 1,
'portfolio/summary/total_resting_order_value': 1,
'portfolio/subaccounts/balances': 1,
'portfolio/subaccounts/netting': 1,
'portfolio/subaccounts/transfers': 1,
'historical/fills': 1,
'historical/orders': 1,
},
'post': {
'portfolio/orders': 1,
'portfolio/events/orders': 1,
'portfolio/orders/batched': 1,
'portfolio/orders/{order_id}/amend': 1,
'portfolio/orders/{order_id}/decrease': 1,
'portfolio/order_groups/create': 1,
'portfolio/subaccounts': 1,
'portfolio/subaccounts/transfer': 1,
'multivariate_event_collections/{collection_ticker}': 1,
},
'put': {
'portfolio/order_groups/{order_group_id}/reset': 1,
'portfolio/order_groups/{order_group_id}/trigger': 1,
'portfolio/order_groups/{order_group_id}/limit': 1,
'portfolio/subaccounts/netting': 1,
'multivariate_event_collections/{collection_ticker}/lookup': 1,
},
'delete': {
'portfolio/orders/{order_id}': 1,
'portfolio/orders/batched': 1,
'portfolio/events/orders/{order_id}': 1, // v2 cancel (the non-v2 paths above are 410 Gone)
'portfolio/order_groups/{order_group_id}': 1,
},
},
},
'elections': {
'public': {
'get': {
'search/series': 1, // free-text series/event search — elections web host only
},
},
},
},
'requiredCredentials': {
'apiKey': true, // KALSHI-ACCESS-KEY (UUID)
'secret': false, // not used — signing is RSA with privateKey, override base default
'privateKey': true, // RSA PEM private key for signing
},
'fees': {
'trading': {
'tierBased': false,
'percentage': true,
'maker': 0.0,
'taker': 0.07, // 7% fee on profit
},
},
'exceptions': {
'exact': {
'not_found': errors.BadSymbol, // 404 for an unknown market/ticker id — distinguish from an outage
'invalid_order': errors.InvalidOrder, // bad price/size/params on a 400
'fill_or_kill_insufficient_resting_volume': errors.OrderNotFillable, // a killed FOK is a normal outcome, not an outage
},
'broad': {},
},
'options': {
'defaultFetchEventsLimit': 200, // events page size for the per-series /events cursor scan
'maxFetchMarketsLimit': 1000, // markets page size / max markets collected per unscoped listing
'searchSeriesLimit': 25, // page_size for the free-text series search endpoint (used when no limit is given)
'maxFetchEventsResults': 100, // default cap on events actually fetched when the caller gives no limit
'maxEventPagesPerSeries': 20, // safety cap on /events pages fetched per resolved series
'defaultEventStatus': 'open', // 'open' | 'closed' | 'settled'
// venue-specific fetchEvents scope params accepted by requireEventQuery in
// addition to the unified query/queries/tags/eventId/slug
'eventScopeParams': ['category', 'series_ticker'],
},
});
}
/**
* @method
* @name kalshi#fetchMarkets
* @description fetches kalshi markets; with a query it resolves the query via the events endpoint and returns the matched events' markets, otherwise it pages the markets listing
* @see https://trading-api.readme.io/reference/getmarkets
* @param {object} [params] extra parameters specific to the exchange API endpoint
* @param {string} [params.query] a single search query; resolved against the events endpoint (event title/ticker), then the matched events' markets are returned
* @param {string[]} [params.queries] multiple search queries (alternative to query); markets from any matching event are returned
* @param {int} [params.limit] for an unscoped listing (no query), the max number of markets to collect (defaults to options.maxFetchMarketsLimit, 1000)
* @returns {object[]} an array of objects representing market data
*/
async fetchMarkets(params = {}) {
const queries = this.parseSearchQueries(params);
const queriesLength = queries.length;
// kalshi's public markets endpoint has no free-text search, so a query would otherwise
// force a client-side scan of every open market (thousands, paged 1000 at a time, which
// hangs). Resolve the query against the events endpoint instead — it is bounded by
// maxPages, scoped server-side, supports multiple topics, and returns each event's parsed
// markets — then flatten those markets.
if (queriesLength > 0) {
const eventParams = this.omit(params, ['limit']);
const events = await this.fetchEvents(eventParams);
const eventsLength = events.length;
const queryMarkets = [];
for (let ei = 0; ei < eventsLength; ei++) {
const eventMarkets = this.safeList(events[ei], 'markets', []);
const eventMarketsLength = eventMarkets.length;
for (let mi = 0; mi < eventMarketsLength; mi++) {
queryMarkets.push(eventMarkets[mi]);
}
}
return queryMarkets;
}
const rest = this.omit(params, ['query', 'queries', 'limit']);
// no query: page the markets listing directly. Cap the total collected so an unscoped
// loadMarkets cannot run away through every kalshi market via the cursor.
const maxMarkets = this.safeInteger(params, 'limit', this.safeInteger(this.options, 'maxFetchMarketsLimit', 1000));
const flatMarkets = [];
const eventsDict = {};
let cursor = undefined;
// don't request a full 1000-market page (3+ MB) when the caller wants fewer
const pageLimit = this.safeInteger(this.options, 'marketsPageLimit', 1000);
const limit = Math.min(maxMarkets, pageLimit);
// default to tradeable (open) markets; kalshi has thousands of closed/settled markets and
// an unfiltered cursor pages through those, so loadMarkets would otherwise return mostly
// closed markets. Pass params.status (e.g. 'closed', 'settled', 'unopened') to override
const status = this.safeString(rest, 'status', 'open');
while (true) {
const request = { 'limit': limit, 'status': status };
if (cursor !== undefined) {
request['cursor'] = cursor;
}
const response = await this.kalshiPublicGetMarkets(this.extend(request, rest));
const rawMarkets = this.safeList(response, 'markets', []);
const rawMarketsLength = rawMarkets.length;
for (let i = 0; i < rawMarkets.length; i++) {
const raw = rawMarkets[i];
const parsed = this.parseBinaryMarketToOutcomes(raw);
const eventTicker = this.safeString(raw, 'event_ticker');
const eventTitle = this.safeString(raw, 'title', eventTicker);
const eventKey = eventTitle ? this.shortenSlug(eventTitle) : undefined;
for (let j = 0; j < parsed.length; j++) {
const m = parsed[j];
flatMarkets.push(m);
if (eventKey) {
if (!(eventKey in eventsDict)) {
eventsDict[eventKey] = {
'id': eventTicker,
'slug': eventTicker,
'event': eventKey,
'title': eventTitle,
'markets': [],
};
}
const eventEntry = eventsDict[eventKey];
// push through a local and write the slice back — the go transpiler's
// AppendToArray reassigns only a local copy of a map-stored array, so a
// direct push on eventEntry['markets'] loses the element in go
const entryMarkets = eventEntry['markets'];
entryMarkets.push(m);
eventEntry['markets'] = entryMarkets;
}
}
}
cursor = this.safeString(response, 'cursor');
const collectedLength = flatMarkets.length;
if (!cursor || rawMarketsLength < limit || collectedLength >= maxMarkets) {
break;
}
}
this.events = eventsDict;
const flatMarketsLength = flatMarkets.length;
if (flatMarketsLength > maxMarkets) {
return this.arraySlice(flatMarkets, 0, maxMarkets);
}
return flatMarkets;
}
parseBinaryMarketToOutcomes(raw) {
return [this.parseMarket(raw)];
}
/**
* @ignore
* @method
* @name kalshi#fetchOutcome
* @description resolves a single outcome on demand instead of bulk-loading. kalshi has tens of
* thousands of markets, so an id-form miss fetches just the requested market by ticker, and a
* handle-form miss resolves through the series-scoped events listing (a handle's first token is
* its series ticker); both merge into the cache so repeat lookups are free
* @param {string} outcomeSymbol an outcome id — a kalshi ticker, or a ticker with a '-NO' suffix — or a unified handle like KXBTCD_26JUL1417_53_000_ABOVE:YES
* @returns {object} the resolved outcome object
*/
async fetchOutcome(outcomeSymbol) {
// a kalshi ticker never contains ':', so only id-form inputs can be fetched by ticker —
// sending a unified handle (EVENT_MARKET:LABEL) as a ticker is a guaranteed 404.
// the indexOf comparison must stay INLINE and `< 0` — the php transpiler only rewrites the
// inline form to mb_strpos's `=== false`; assigned to a variable first, absence (false)
// never satisfies `< 0` and id-form inputs take the wrong branch
if (outcomeSymbol.indexOf(':') < 0) {
// parseToInt-wrapped .length: the bare `const n = str.length;` statement is the php
// transpiler's ARRAY hint (count()), and `.length` inline inside slice() args breaks
// the python transpiler — this form emits strlen()/len() correctly in both
const symbolLength = this.parseToInt(outcomeSymbol.length);
const suffix = outcomeSymbol.slice(symbolLength - 3);
const isNo = (suffix === '-NO');
const baseTicker = isNo ? outcomeSymbol.slice(0, symbolLength - 3) : outcomeSymbol;
let response = undefined;
try {
response = await this.kalshiPublicGetMarketsTicker({ 'ticker': baseTicker });
}
catch (e) {
// an unknown ticker returns 'not_found', which handleErrors maps to BadSymbol —
// fall through to the search-driven base resolution; let network failures propagate
if (!(e instanceof errors.BadSymbol)) {
throw e;
}
response = undefined;
}
if (response !== undefined) {
const rawMarket = this.safeDict(response, 'market', response);
const parsed = this.parseMarket(rawMarket);
if (this.markets === undefined) {
this.markets = this.createSafeDictionary();
}
if (parsed === undefined) {
throw new errors.ExchangeError(this.id + ' fetchOutcome() could not resolve parsed');
}
this.markets[parsed['market']] = parsed;
// index only the market just fetched, not a full O(markets x outcomes) rebuild of the
// whole cache — on-demand fetchOutcome (loadAllOutcomes false) is the hot path here
this.indexMarketOutcomes(parsed);
return this.outcome(outcomeSymbol);
}
}
else {
// handle-form: handles are shortenSlug(event_ticker) + '_' + <market slug> and kalshi
// series tickers are single alphanumeric segments, so the handle's first '_' token is
// its series ticker — fetch that series' open events (server-side filter, one page in
// the common case) and re-check the cache for the exact handle
const handleParts = outcomeSymbol.split(':');
const marketPart = this.safeString(handleParts, 0, '');
const parts = marketPart.split('_');
const seriesTicker = this.safeString(parts, 0);
if ((seriesTicker !== undefined) && (seriesTicker !== '')) {
try {
await this.fetchEvents({ 'series_ticker': seriesTicker });
}
catch (e) {
// an unknown series is a plain miss — the free-text fallback below still runs;
// let network failures propagate
if (!(e instanceof errors.BadSymbol)) {
throw e;
}
}
if (this.hasOutcome(outcomeSymbol)) {
return this.safeOutcome(outcomeSymbol);
}
}
}
// free-text fallback: the base derives a search query from the handle's words, resolves it
// through fetchEvents({query}) and re-checks the cache, throwing a guidance-rich BadSymbol
// on a genuine miss
return await super.fetchOutcome(outcomeSymbol);
}
/**
* @ignore
* @method
* @name kalshi#fetchOutcomes
* @description resolves several uncached outcomes at once — ticker-shaped ids are batched through the markets listing's tickers filter (100 per request); anything left unresolved (handle-shaped symbols, unknown tickers) falls back to the single fetch and its guidance-rich BadSymbol
* @see https://docs.kalshi.com/api-reference/market/get-markets
* @param {string[]} outcomeSymbols kalshi tickers (optionally with a '-NO' suffix) or outcome handles
* @returns {object} the outcome cache
*/
async fetchOutcomes(outcomeSymbols) {
const tickers = [];
const seen = {};
for (let i = 0; i < outcomeSymbols.length; i++) {
const outcomeSymbol = outcomeSymbols[i];
if (outcomeSymbol.indexOf(':') >= 0) {
continue;
}
// parseToInt-wrapped .length — see the fetchOutcome comment (php count()/python slice traps)
const symbolLength = this.parseToInt(outcomeSymbol.length);
const suffix = outcomeSymbol.slice(symbolLength - 3);
const baseTicker = (suffix === '-NO') ? outcomeSymbol.slice(0, symbolLength - 3) : outcomeSymbol;
if (!(baseTicker in seen)) {
seen[baseTicker] = true;
tickers.push(baseTicker);
}
}
if (this.markets === undefined) {
this.markets = this.createSafeDictionary();
}
const chunkSize = this.safeInteger(this.options, 'fetchOutcomesBatchSize', 100);
const tickersLength = tickers.length;
let startIndex = 0;
while (startIndex < tickersLength) {
let endIndex = this.sum(startIndex, chunkSize);
if (endIndex > tickersLength) {
endIndex = tickersLength;
}
const chunk = [];
for (let i = startIndex; i < endIndex; i++) {
chunk.push(tickers[i]);
}
const request = {
'tickers': chunk.join(','),
'limit': chunkSize,
};
const response = await this.kalshiPublicGetMarkets(request);
const rawMarkets = this.safeList(response, 'markets', []);
for (let i = 0; i < rawMarkets.length; i++) {
const parsed = this.parseMarket(rawMarkets[i]);
if (parsed === undefined) {
throw new errors.ExchangeError(this.id + ' fetchOutcomes() could not resolve parsed');
}
this.markets[parsed['market']] = parsed;
this.indexMarketOutcomes(parsed);
}
startIndex = this.sum(startIndex, chunkSize);
}
for (let i = 0; i < outcomeSymbols.length; i++) {
if (!this.hasOutcome(outcomeSymbols[i])) {
await this.fetchOutcome(outcomeSymbols[i]);
}
}
return this.outcomes;
}
handleErrors(code, reason, url, method, headers, body, response, requestHeaders, requestBody) {
// kalshi returns { "error": { "code": "...", ... } } with a 4xx; map known codes to ccxt
// errors (e.g. not_found -> BadSymbol) so callers can distinguish them from a transport
// outage (the base otherwise maps a bare 404 to the exchange-not-available error). unmapped codes fall
// through to the base http-status handling.
if (!response) {
return undefined;
}
const error = this.safeDict(response, 'error');
if (error !== undefined) {
const errorCode = this.safeString(error, 'code');
const feedback = this.id + ' ' + body;
this.throwExactlyMatchedException(this.exceptions['exact'], errorCode, feedback);
this.throwBroadlyMatchedException(this.exceptions['broad'], errorCode, feedback);
}
// a 400 is a client-side bad request (bad params, invalid order), not a transport outage —
// throw BadRequest instead of letting the base map the bare 400 to a retryable network-unavailable error
if (code === 400) {
const feedback = this.id + ' ' + body;
throw new errors.BadRequest(feedback);
}
return undefined;
}
calculateFee(symbol, type, side, amount, price, takerOrMaker = 'taker', params = {}) {
// kalshi's trading fee is NOT a flat 7% — it is 0.07 * contracts * price * (1 - price), which
// peaks at price 0.5 and vanishes near 0 or 1. the describe() `taker: 0.07` is only the
// coefficient; compute the real per-contract formula here so fee estimates are accurate
const priceStr = this.numberToString(price);
const amountStr = this.numberToString(amount);
const oneMinusP = Precise["default"].stringSub('1', priceStr);
let feeCost = Precise["default"].stringMul('0.07', amountStr);
feeCost = Precise["default"].stringMul(feeCost, priceStr);
feeCost = Precise["default"].stringMul(feeCost, oneMinusP);
return {
'type': takerOrMaker,
'currency': 'USD',
'rate': 0.07,
'cost': this.parseNumber(feeCost),
};
}
parseMarket(raw) {
// {
// "can_close_early":true,
// "close_time":"2029-07-01T14:00:00Z",
// "created_time":"0001-01-01T00:00:00Z",
// "early_close_condition":"This market will close and expire early if the event occurs.",
// "event_ticker":"KXBALANCE-29",
// "expected_expiration_time":"2029-07-01T14:00:00Z",
// "expiration_time":"2029-07-01T14:00:00Z",
// "expiration_value":"",
// "fractional_trading_enabled":false,
// "last_price_dollars":"0.1100",
// "latest_expiration_time":"2029-07-01T14:00:00Z",
// "liquidity_dollars":"0.0000",
// "market_type":"binary",
// "no_ask_dollars":"0.9000",
// "no_bid_dollars":"0.8900",
// "no_sub_title":"During Trump's term",
// "notional_value_dollars":"1.0000",
// "open_interest_fp":"16353.00",
// "open_time":"2025-01-03T15:00:00Z",
// "previous_price_dollars":"0.0000",
// "previous_yes_ask_dollars":"0.0000",
// "previous_yes_bid_dollars":"0.0000",
// "price_level_structure":"linear_cent",
// "price_ranges":[
// {
// "end":"1.0000",
// "start":"0.0000",
// "step":"0.0100"
// }
// ],
// "response_price_units":"usd_cent",
// "result":"",
// "rules_primary":"If there is not a budget deficit for any of fiscal years 2025, 2026, 2027, or 2028, then the market resolves to Yes.",
// "rules_secondary":"",
// "settlement_timer_seconds":"1800",
// "status":"active",
// "subtitle":"",
// "tick_size":"1",
// "ticker":"KXBALANCE-29",
// "title":"Will Trump balance the budget?",
// "updated_time":"0001-01-01T00:00:00Z",
// "volume_24h_fp":"85.00",
// "volume_fp":"40208.00",
// "yes_ask_dollars":"0.1100",
// "yes_ask_size_fp":"",
// "yes_bid_dollars":"0.1000",
// "yes_bid_size_fp":"",
// "yes_sub_title":"During Trump's term"
// }
const ticker = this.safeString(raw, 'ticker');
const eventTicker = this.safeString(raw, 'event_ticker');
const subtitle = this.safeString(raw, 'subtitle', this.safeString(raw, 'title'));
// markets use status 'active' while events use 'open'
const status = this.safeString(raw, 'status');
const active = (status === 'active') || (status === 'open');
// resolution: kalshi sets `result` to 'yes'/'no' once the market settles (empty while trading)
const result = this.safeStringLower(raw, 'result');
const resolved = (status === 'settled') || ((result !== undefined) && (result !== ''));
const endDate = this.safeString(raw, 'expiration_time');
const volume = this.safeNumber2(raw, 'volume_fp', 'volume');
const liquidity = this.safeNumber2(raw, 'liquidity_dollars', 'liquidity');
const openInt = this.safeNumber2(raw, 'open_interest_fp', 'open_interest');
// Derive series ticker: drop last hyphen-segment from event_ticker
let eventParts = [];
if (eventTicker) {
eventParts = eventTicker.split('-');
}
let seriesTicker = eventTicker;
const eventPartsLength = eventParts.length;
if (eventPartsLength > 1) {
const seriesParts = this.arraySlice(eventParts, 0, eventPartsLength - 1);
seriesTicker = seriesParts.join('-');
}
// market symbol (no outcome suffix)
const subtitleOrTicker = (subtitle !== undefined) ? subtitle : ticker;
const marketSymbol = this.slugToMarketSymbol(eventTicker, subtitleOrTicker);
// kalshi exposes the per-market price tick via price_ranges[].step (a dollar value,
// e.g. "0.0010" for deci-cent markets, "0.0100" for cent markets); older responses
// used tick_size (in cents). amount is a whole number of contracts
const priceRanges = this.safeList(raw, 'price_ranges', []);
const firstRange = this.safeDict(priceRanges, 0, {});
const stepDollars = this.safeString(firstRange, 'step');
let pricePrecision = this.parseNumber(Precise["default"].stringDiv(this.safeString(raw, 'tick_size', '1'), '100'));
if (stepDollars !== undefined) {
pricePrecision = this.parseNumber(stepDollars);
}
const precision = {
'amount': 1,
'price': pricePrecision,
};
// Build outcomes
const outcomeLabels = ['YES', 'NO'];
const outcomeIds = [ticker, ticker + '-NO'];
const outcomes = [];
let resolvedOutcome = undefined;
for (let oi = 0; oi < outcomeLabels.length; oi++) {
const label = outcomeLabels[oi];
const outcomeHandle = this.slugToOutcomeSymbol(eventTicker, subtitleOrTicker, label);
let winnerRaw = undefined;
let settleFractionRaw = undefined;
if (resolved && (result !== undefined) && (result !== '')) {
winnerRaw = (label.toLowerCase() === result);
settleFractionRaw = (winnerRaw) ? 1 : 0;
if (winnerRaw) {
resolvedOutcome = outcomeHandle;
}
}
// effectively-final copies for the object literal below (Java cannot capture a
// reassigned local into the anonymous inner class it emits for a map literal)
const winner = winnerRaw;
const settleFraction = settleFractionRaw;
outcomes.push({
'id': outcomeIds[oi],
'outcomeId': outcomeIds[oi],
'outcome': outcomeHandle,
'market': marketSymbol,
'label': label,
'active': active,
'winner': winner,
'settleFraction': settleFraction,
'precision': precision,
'info': {
'ticker': ticker,
'eventTicker': eventTicker,
'seriesTicker': seriesTicker,
'subtitle': subtitle,
'outcomeLabel': label,
'volume': volume,
'liquidity': liquidity,
'openInterest': openInt,
},
});
}
// effectively-final copy for the market object literal below (reassigned in the loop)
const marketResolvedOutcome = resolvedOutcome;
return {
'id': ticker,
'market': marketSymbol,
'base': 'USD',
'quote': 'USD',
'settle': undefined,
'baseId': ticker,
'quoteId': 'USD',
'settleId': undefined,
'type': 'prediction',
'marketType': 'binary',
'executionModel': 'clob',
'spot': false,
'margin': false,
'swap': false,
'future': false,
'option': false,
'prediction': true,
'active': active,
'resolved': resolved,
'resolvedOutcome': marketResolvedOutcome,
'contract': false,
'linear': undefined,
'inverse': undefined,
'contractSize': undefined,
'expiry': endDate ? this.parse8601(endDate) : undefined,
'expiryDatetime': endDate,
'strike': undefined,
'optionType': undefined,
'taker': 0.07,
'maker': 0.0,
'percentage': true,
'tierBased': false,
'feeSide': 'get',
'precision': precision,
'limits': {
'leverage': { 'min': 1, 'max': 1 },
'amount': { 'min': 1, 'max': undefined },
'price': { 'min': 0.01, 'max': 0.99 },
'cost': { 'min': undefined, 'max': undefined },
},
'outcomes': outcomes,
'info': this.extend(raw, {
'ticker': ticker,
'eventTicker': eventTicker,
'seriesTicker': seriesTicker,
'subtitle': subtitle,
'volume': volume,
'liquidity': liquidity,
'openInterest': openInt,
}),
'created': undefined,
};
}
/**
* @method
* @name kalshi#fetchTicker
* @description fetches the current market price and bid/ask for a single kalshi outcome
* @see https://docs.kalshi.com/api-reference/market/get-market
* @param {string} outcome the unified outcome like TRUMP_BRING_BACK_MANUFACTURING:YES or outcomeId like KXGDPSHAREMANU-29
* @param {object} [params] extra parameters specific to the exchange API endpoint
* @returns {object} a [prediction ticker structure](https://docs.ccxt.com/#/?id=prediction-ticker-structure)
*/
async fetchTicker(outcome, params = {}) {
await this.loadOutcome(outcome);
const outcomeObj = this.outcome(outcome);
const ticker = this.safeString(outcomeObj['info'], 'ticker');
const request = {
'ticker': ticker,
};
const response = await this.kalshiPublicGetMarketsTicker(this.extend(request, params));
//
// {
// "market": {
// "can_close_early": true,
// "close_time": "2029-06-30T03:59:00Z",
// "created_time": "2025-06-05T17:55:43.779104Z",
// "early_close_condition": "This market will close and expire early if the event occurs.",
// "event_ticker": "KXGDPSHAREMANU-29",
// "expected_expiration_time": "2029-06-30T14:00:00Z",
// "expiration_time": "2029-07-07T14:00:00Z",
// "expiration_value": "",
// "floor_strike": "13.1",
// "fractional_trading_enabled": true,
// "last_price_dollars": "0.1980",
// "latest_expiration_time": "2029-07-07T14:00:00Z",
// "liquidity_dollars": "0.0000",
// "market_type": "binary",
// "no_ask_dollars": "0.8890",
// "no_bid_dollars": "0.8030",
// "no_sub_title": "Before 2029",
// "notional_value_dollars": "1.0000",
// "open_interest_fp": "11077.21",
// "open_time": "2025-06-05T18:00:00Z",
// "previous_price_dollars": "0.1980",
// "previous_yes_ask_dollars": "0.1970",
// "previous_yes_bid_dollars": "0.1110",
// "price_level_structure": "deci_cent",
// "price_ranges": [
// {
// "start": "0.55",
// "end": "0.56",
// "step": "0.01"
// }
// ],
// "response_price_units": "usd_cent",
// "result": "",
// "rules_primary": "If the value added by Manufacturing to GDP in Q4 2028 is at least 13.1% (the value it was in Q1 2005), then the market resolves to Yes.",
// "rules_secondary": "",
// "settlement_timer_seconds": "1800",
// "status": "active",
// "strike_type": "greater_or_equal",
// "tick_size": "1",
// "ticker": "KXGDPSHAREMANU-29",
// "title": "Will Trump bring back manufacturing?",
// "updated_time": "2026-04-09T10:32:47.890506Z",
// "volume_24h_fp": "0.00",
// "volume_fp": "19617.68",
// "yes_ask_dollars": "0.1970",
// "yes_ask_size_fp": "2750.00",
// "yes_bid_dollars": "0.1110",
// "yes_bid_size_fp": "2505.61",
// "yes_sub_title": "Before 2029"
// }
// }
//
const raw = this.safeValue(response, 'market', response);
return this.parsePredictionTicker(raw, outcomeObj);
}
/**
* @method
* @name kalshi#fetchStatus
* @description fetches the kalshi exchange status
* @see https://docs.kalshi.com/api-reference/exchange/get-exchange-status
* @param {object} [params] extra parameters specific to the exchange API endpoint
* @returns {object} a [status structure](https://docs.ccxt.com/#/?id=exchange-status-structure)
*/
async fetchStatus(params = {}) {
const response = await this.kalshiPublicGetExchangeStatus(params);
//
// { "exchange_active": true, "trading_active": true }
//
const tradingActive = this.safeBool(response, 'trading_active', false);
return {
'status': tradingActive ? 'ok' : 'maintenance',
'updated': undefined,
'eta': undefined,
'url': undefined,
'info': response,
};
}
/**
* @method
* @name kalshi#fetchOpenInterest
* @description fetches the open interest of a prediction market outcome
* @see https://docs.kalshi.com/api-reference/market/get-market
* @param {string} outcome unified outcome or outcome id
* @param {object} [params] extra parameters specific to the exchange API endpoint
* @returns {object} an [open interest structure](https://docs.ccxt.com/#/?id=open-interest-structure)
*/
async fetchOpenInterest(outcome, params = {}) {
await this.loadOutcome(outcome);
const outcomeObj = this.outcome(outcome);
const ticker = this.safeString(outcomeObj['info'], 'ticker');
const request = { 'ticker': ticker };
const response = await this.kalshiPublicGetMarketsTicker(this.extend(request, params));
const raw = this.safeDict(response, 'market', response);
return this.parsePredictionOpenInterest(raw, outcomeObj);
}
parsePredictionOpenInterest(interest, market = undefined) {
//
// { "ticker": "...", "open_interest_fp": "60802.01", ... } // open interest in contracts
//
const timestamp = this.milliseconds();
const openInterest = this.safeOpenInterest({
'symbol': this.safeSymbol(undefined, market),
'openInterestAmount': this.safeNumber2(interest, 'open_interest_fp', 'open_interest'),
'openInterestValue': undefined,
'baseVolume': undefined,
'quoteVolume': undefined,
'timestamp': timestamp,
'datetime': this.iso8601(timestamp),
'info': interest,
}, market);
openInterest['outcome'] = this.safeOutcomeSymbol(undefined, market);
openInterest['outcomeId'] = this.safeString(market, 'outcomeId');
delete openInterest['symbol'];
return openInterest;
}
/**
* @ignore
* @method
* @name kalshi#parsePredictionTicker
* @description parses a raw kalshi market object into a unified ticker object
* @param {object} raw the raw market object
* @param {object} [market] the outcome object the ticker belongs to
* @returns {object} a [prediction ticker structure](https://docs.ccxt.com/#/?id=prediction-ticker-structure)
*/
parsePredictionTicker(raw, market = undefined) {
//
// {
// "market": {
// "can_close_early": true,
// "close_time": "2029-06-30T03:59:00Z",
// "created_time": "2025-06-05T17:55:43.779104Z",
// "early_close_condition": "This market will close and expire early if the event occurs.",
// "event_ticker": "KXGDPSHAREMANU-29",
// "expected_expiration_time": "2029-06-30T14:00:00Z",
// "expiration_time": "2029-07-07T14:00:00Z",
// "expiration_value": "",
// "floor_strike": "13.1",
// "fractional_trading_enabled": true,
// "last_price_dollars": "0.1980",
// "latest_expiration_time": "2029-07-07T14:00:00Z",
// "liquidity_dollars": "0.0000",
// "market_type": "binary",
// "no_ask_dollars": "0.8890",
// "no_bid_dollars": "0.8030",
// "no_sub_title": "Before 2029",
// "notional_value_dollars": "1.0000",
// "open_interest_fp": "11077.21",
// "open_time": "2025-06-05T18:00:00Z",
// "previous_price_dollars": "0.1980",
// "previous_yes_ask_dollars": "0.1970",
// "previous_yes_bid_dollars": "0.1110",
// "price_level_structure": "deci_cent",
// "price_ranges": [
// {
// "start": "0.55",
// "end": "0.56",
// "step": "0.01"
// }
// ],
// "response_price_units": "usd_cent",
// "result": "",
// "rules_primary": "If the value added by Manufacturing to GDP in Q4 2028 is at least 13.1% (the value it was in Q1 2005), then the market resolves to Yes.",
// "rules_secondary": "",
// "settlement_timer_seconds": "1800",
// "status": "active",
// "strike_type": "greater_or_equal",
// "tick_size": "1",
// "ticker": "KXGDPSHAREMANU-29",
// "title": "Will Trump bring back manufacturing?",
// "updated_time": "2026-04-09T10:32:47.890506Z",
// "volume_24h_fp": "0.00",
// "volume_fp": "19617.68",
// "yes_ask_dollars": "0.1970",
// "yes_ask_size_fp": "2750.00",
// "yes_bid_dollars": "0.1110",
// "yes_bid_size_fp": "2505.61",
// "yes_sub_title": "Before 2029"
// }
// }
//
const marketAny = market;
const outcomeObj = this.safeOutcome(this.safeString(marketAny, 'outcome'), marketAny);
const outcomeLabel = market ? this.safeString(market, 'label', this.safeString(market['info'], 'outcomeLabel', 'YES')) : 'YES';
const isNo = outcomeLabel.toUpperCase() === 'NO';
const now = this.milliseconds();
const outcome = this.safeString(outcomeObj, 'outcome');
const yesAsk = this.safeNumber(raw, 'yes_ask_dollars');
const yesBid = this.safeNumber(raw, 'yes_bid_dollars');
const noAsk = this.safeNumber(raw, 'no_ask_dollars');
const noBid = this.safeNumber(raw, 'no_bid_dollars');
const last = this.safeNumber(raw, 'last_price_dollars');
let bid;
let ask;
let close;
if (isNo) {
bid = noBid;
ask = noAsk;
close = (last !== undefined) ? this.parseNumber(Precise["default"].stringSub('1', this.numberToString(last))) : undefined;
}
else {
bid = yesBid;
ask = yesAsk;
close = last;
}
// the book is quoted in the yes token, the no side mirrors with sizes swapped
const bidSizeString = (isNo) ? this.safeString(raw, 'yes_ask_size_fp') : this.safeString(raw, 'yes_bid_size_fp');
const askSizeString = (isNo) ? this.safeString(raw, 'yes_bid_size_fp') : this.safeString(raw, 'yes_ask_size_fp');
// kalshi occasionally reports a negative size for settling/closed markets; a size
// can't be negative, so drop it rather than emit an invalid volume
let bidVolume = undefined;
if ((bidSizeString !== undefined) && Precise["default"].stringGe(bidSizeString, '0')) {
bidVolume = this.parseNumber(bidSizeString);
}
let askVolume = undefined;
if ((askSizeString !== undefined) && Precise["default"].stringGe(askSizeString, '0')) {
askVolume = this.parseNumber(askSizeString);
}
let average = undefined;
if ((bid !== undefined) && (ask !== undefined)) {
average = this.parseNumber(Precise["default"].stringDiv(Precise["default"].stringAdd(this.numberToString(bid), this.numberToString(ask)), '2'));
}
return this.safePredictionTicker({
'outcome': outcome,
'outcomeId': this.safeString2(outcomeObj, 'outcomeId', 'id'),
'label': this.safeString(outcomeObj, 'label'),
'market': this.safeString2(outcomeObj, 'market', 'outcome'),
'timestamp': now,
'datetime': this.iso8601(now),
'high': undefined,
'low': undefined,
'bid': bid,
'bidVolume': bidVolume,
'ask': ask,
'askVolume': askVolume,
'vwap': undefined,
'open': undefined,
'close': close,
'last': close,
'previousClose': undefined,
'change': undefined,
'percentage': undefined,
'average': average,
'baseVolume': this.safeNumberN(raw, ['volume_24h_fp', 'volume_24h', 'volume']), // 24h volume in contracts
'quoteVolume': undefined,
'info': raw,
}, market);
}
/**
* @method
* @name kalshi#fetchTickers
* @description fetches tickers for multiple outcomes at once, batching their market tickers through the markets endpoint (100 per request)
* @see https://docs.kalshi.com/api-reference/market/get-markets
* @param {string[]} outcomes unified outcomes — required: kalshi has tens of thousands of markets and no endpoint returning all tickers at once, so an unscoped call is not supported
* @param {object} [params] extra parameters specific to the exchange API endpoint
* @returns {object} a dictionary of [prediction ticker structures](https://docs.ccxt.com/#/?id=prediction-ticker-structure) indexed by outcome
*/
async fetchTickers(outcomes = undefined, params = {}) {
if (outcomes === undefined) {
throw new errors.ArgumentsRequired(this.id + ' fetchTickers() requires an outcomes argument — the venue has no all-tickers endpoint; pass the outcome handles to fetch (discover them via fetchEvents ())');
}
// batch-resolve the uncached outcomes (one markets request per 100 tickers)
await this.loadOutcomes(outcomes);
const targets = [];
for (let i = 0; i < outcomes.length; i++) {
targets.push(outcomes[i]);
}
// group requested outcomes by their market ticker, yes and no outcomes share one market
const outcomesByTicker = {};
const tickers = [];
for (let i = 0; i < targets.length; i++) {
const outcomeObj = this.outcome(targets[i]);
const ticker = this.safeString(outcomeObj['info'], 'ticker');
if (ticker === undefined) {
continue;
}
if (!(ticker in outcomesByTicker)) {
outcomesB