UNPKG

ccxt

Version:

A cryptocurrency trading API with more than 100 exchanges in JavaScript / TypeScript / Python / C# / PHP / Go

986 lines (984 loc) 131 kB
// ---------------------------------------------------------------------------- // PLEASE DO NOT EDIT THIS FILE, IT IS GENERATED AND WILL BE OVERWRITTEN: // https://github.com/ccxt/ccxt/blob/master/CONTRIBUTING.md#how-to-contribute-code // EDIT THE CORRESPONDENT .ts FILE INSTEAD import { sha256 } from '@noble/hashes/sha2.js'; import Exchange from '../abstract/prediction/kalshi.js'; import { Precise } from '../base/Precise.js'; import { rsa } from '../base/functions/rsa.js'; import { BadSymbol, ArgumentsRequired, BadRequest, OrderNotFillable, InvalidOrder, ExchangeError } from '../base/errors.js'; // --------------------------------------------------------------------------- /** * @class kalshi * @augments Exchange */ export default class kalshi extends Exchange { 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': BadSymbol, // 404 for an unknown market/ticker id — distinguish from an outage 'invalid_order': InvalidOrder, // bad price/size/params on a 400 'fill_or_kill_insufficient_resting_volume': 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 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 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 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 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 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.stringSub('1', priceStr); let feeCost = Precise.stringMul('0.07', amountStr); feeCost = Precise.stringMul(feeCost, priceStr); feeCost = Precise.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.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.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.stringGe(bidSizeString, '0')) { bidVolume = this.parseNumber(bidSizeString); } let askVolume = undefined; if ((askSizeString !== undefined) && Precise.stringGe(askSizeString, '0')) { askVolume = this.parseNumber(askSizeString); } let average = undefined; if ((bid !== undefined) && (ask !== undefined)) { average = this.parseNumber(Precise.stringDiv(Precise.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 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 outcomesByTi