@demo-npm-test/prediction-market-sdk
v0.1.0
Published
Unified TypeScript SDK for prediction markets (Polymarket, Kalshi, predict.fun, Opinion Labs).
Maintainers
Readme
prediction-market-sdk
A TypeScript SDK that exposes a single, normalized interface over multiple prediction-market venues — Polymarket, Kalshi, predict.fun, and Opinion Labs. Write your code once against one contract; never branch on venue.
- Read-only (v1): market data, orderbooks, trades, balances, positions, open orders.
- Isomorphic: Node 20+ and modern browsers. Native
fetch+ Web Crypto only. - Zero runtime dependencies.
- Strict, normalized types — prices are always implied probabilities in
[0, 1]; money is always USD with a ready-to-use dollarvalue(and a lossless integeramount).
Status: pre-release (
0.0.0). Trading writes and WebSocket streaming are not implemented — see Scope.
Install
npm install prediction-market-sdk
# or: pnpm add prediction-market-sdkQuick start
import { PolymarketClient, KalshiClient, PredictFunClient } from 'prediction-market-sdk';
// Polymarket market data needs no credentials.
const poly = await PolymarketClient.create();
const markets = await poly.getMarkets({ status: 'open', limit: 5 });
for (const m of markets) {
const odds = m.outcomes.map((o) => `${o.name} ${(o.probability * 100).toFixed(1)}%`);
console.log(m.title, '→', odds.join(' / '));
}
// Kalshi market data also needs no credentials.
const kalshi = await KalshiClient.create();
const book = await kalshi.getOrderbook('SOME-TICKER'); // defaults to the YES side
console.log(book.bids[0], book.asks[0]);
// predict.fun mainnet needs an API key — but its testnet is fully keyless.
const predict = await PredictFunClient.create({ testnet: true });
console.log(await predict.getTrendingMarkets({ limit: 5 }));All clients implement the same abstract base, so you can program against the contract instead of the concrete venue:
import type { PredictionMarketClient } from 'prediction-market-sdk';
async function topMarket(client: PredictionMarketClient) {
const [first] = await client.getMarkets({ status: 'open', limit: 1 });
return first; // a normalized Market, identical shape across venues
}Construction
Every client is created with an async factory (create), never new.
KalshiClient.create(options?)
const kalshi = await KalshiClient.create({
apiKeyId: process.env.KALSHI_API_KEY_ID, // required for portfolio methods
privateKeyPem: process.env.KALSHI_PRIVATE_KEY, // PKCS#8 PEM ("-----BEGIN PRIVATE KEY-----")
});| Option | Type | Default | Notes |
|---|---|---|---|
| apiKeyId | string | — | Kalshi API key id. Required (with privateKeyPem) for getBalance/getPositions/getOpenOrders. |
| privateKeyPem | string | — | PKCS#8 PEM. A PKCS#1 key (-----BEGIN RSA PRIVATE KEY-----) is rejected with a conversion hint. |
| auth | AuthStrategy | — | Pre-built strategy; overrides the two fields above. Advanced/testing. |
| baseUrl | string | https://api.elections.kalshi.com/trade-api/v2 | Set to https://demo-api.kalshi.co/trade-api/v2 for demo. |
| timeoutMs | number | 30000 | Per-request timeout. |
| fetch | typeof fetch | global fetch | Inject for tests. |
| sleep | (ms) => Promise<void> | setTimeout | Inject for tests. |
| now | () => number | Date.now | Clock for signing + orderbook timestamps. |
Market-data methods (getMarkets, getMarket, getOrderbook, getTrades) work
without credentials. Portfolio methods require apiKeyId + privateKeyPem.
PolymarketClient.create(options?)
const poly = await PolymarketClient.create({
walletAddress: '0x…', // required for getPositions / getBalance / getPortfolioValue
});| Option | Type | Default | Notes |
|---|---|---|---|
| walletAddress | string | — | A public proxy-wallet address. Required for getPositions/getBalance/getPortfolioValue. |
| gammaBaseUrl | string | https://gamma-api.polymarket.com | Market-data API. |
| clobBaseUrl | string | https://clob.polymarket.com | Orderbook API. |
| dataBaseUrl | string | https://data-api.polymarket.com | Trades/positions/value API. |
| rpcUrl | string | https://polygon-bor-rpc.publicnode.com | Polygon JSON-RPC, used by getBalance for the on-chain USDC cash balance. |
| timeoutMs | number | 30000 | Per-request timeout. |
| fetch | typeof fetch | global fetch | Inject for tests. |
| sleep | (ms) => Promise<void> | setTimeout | Inject for tests. |
| now | () => number | Date.now | Clock for orderbook timestamps. |
| signer | Signer | — | Signs orders + ClobAuth. Use new LocalSigner('0x…') for a local secp256k1 key, or any external Signer (Privy/Turnkey/viem/ethers). Both are exported. Required for placeOrder/buildSignedOrder. |
| funderAddress | string | derived from signatureType | Funds owner (proxy/funder). Defaults to the signer's EOA (sigType 0), its Safe (sigType 2), or its deposit wallet (sigType 3). |
| signatureType | 0 \| 1 \| 2 \| 3 | 3 | 0 EOA, 1 Polymarket proxy, 2 Gnosis-Safe proxy, 3 deposit wallet (POLY_1271). Defaults to 3 — Polymarket mandates it for all new API accounts; pre-existing EOA/proxy/Safe users must set 0/1/2 explicitly. sigType 3 is implemented but LIVE-unverified — see the note below. |
| clobApiKey / clobSecret / clobPassphrase | string | auto-derived | CLOB L2 API credentials. Optional — a client with just a signer derives and caches these itself on the first authenticated call. Pass them only to skip that one-time round trip (see deriveApiCreds()). |
| exchangeAddress | string | CTF Exchange V2 (CTF_EXCHANGE) | EIP-712 verifying contract; pass the exported NEG_RISK_CTF_EXCHANGE for neg-risk markets. |
| chainId | number | 137 | EIP-712 chain id (Polygon). |
Read endpoints are unauthenticated; walletAddress is an address, not a
credential — it identifies whose positions/value to read. The trading options
above are only needed for placeOrder.
Credentials are automatic — just create a client and trade
The clobApiKey/clobSecret/clobPassphrase are L2 credentials derived from
your signer (you don't get them from a dashboard). You don't have to manage
them: a client built with only a signer derives them itself on its first
authenticated call and caches them, so the whole flow is one step:
import { PolymarketClient, LocalSigner } from 'prediction-market-sdk';
const client = await PolymarketClient.create({
signer: new LocalSigner('0x…'),
signatureType: 1, // optional; defaults to 3 (deposit wallet)
});
// Creds are derived + cached under the hood — this just works:
await client.placeOrder({ marketId, outcomeId, side: 'buy', price: 0.5, size: 10, tif: 'gtc' });deriveApiCreds() (Polymarket only)
Call this only if you want to inspect or persist the creds — e.g. to store
them and pass them back into create next time, skipping the one-time derivation
round trip:
const poly = await PolymarketClient.create({ signer: new LocalSigner('0x…') });
const creds = await poly.deriveApiCreds(); // → { apiKey, secret, passphrase }
const trading = await PolymarketClient.create({
signer: new LocalSigner('0x…'),
signatureType: 1,
clobApiKey: creds.apiKey,
clobSecret: creds.secret,
clobPassphrase: creds.passphrase,
});The call is idempotent — the same key always maps to the same credentials,
so re-running it is safe. Under the hood it is L1-signed (EIP-712 ClobAuth) and
create-or-derive: POST /auth/api-key (create) first, falling back to
GET /auth/derive-api-key only if create returns no key. This is why a
brand-new wallet works — it has no key to derive yet, so the create step runs
first. Returns PolymarketApiCreds. Requires
only a signer. This is a Polymarket-only helper (not on the base
PredictionMarketClient). The playground (pnpm playground) exposes it under
its "Setup" group with a one-click "Save to Polymarket credentials" button.
Wallet topology (
signatureType): forsignatureType: 1(the common Email/Magic wallet),funderAddressandwalletAddressare the same address — the Magic smart-wallet (proxy) — and both differ from the signer (the EOA thesignercontrols). ForsignatureType: 0(EOA) the signer is the funder, sofunderAddresscan be omitted. ForsignatureType: 2(Gnosis-Safe) andsignatureType: 3(deposit wallet), the funder defaults to the EOA's deterministic Safe / deposit-wallet address, so it can be omitted too when the EOA owns that wallet.
signatureType: 3(deposit wallet / POLY_1271) is LIVE-unverified. The account model Polymarket mandates for new API users. The SDK builds it end-to-end — the deposit-wallet CREATE2 address (verified on-chain) and the Solady ERC-7739TypedDataSignorder envelope, whose signing path is verified byte-for-byte against@polymarket/clob-client-v21.0.8. The owner EOA (viaLocalSigneror an externalSigner) produces the raw ECDSA;maker == signer == depositWallet, while the CLOB credentials bind to the owner EOA (plain ClobAuth,POLY_ADDRESS = EOA) — same as 1.0.8. Whether the live CLOB accepts an end-to-end submit is not yet confirmed against a real response, so treat as experimental until thePOLY_SIGTYPE3_SUBMIT=1LIVE check passes.
PredictFunClient.create(options?)
import { PredictFunClient, LocalSigner } from 'prediction-market-sdk';
const predict = await PredictFunClient.create({
apiKey: process.env.PREDICTFUN_API_KEY, // mainnet reads need one
walletAddress: '0x…', // portfolio reads
signer: new LocalSigner(process.env.PREDICTFUN_PRIVATE_KEY!), // trading
});
// Email/social sign-in account (Predict account / smart wallet): pass the
// deposit address from predict.fun → Account → Settings plus the exported
// Privy wallet key. Orders + auth then act AS the account — its balances and
// allowances apply, so no manual approvals are needed.
const account = await PredictFunClient.create({
apiKey: process.env.PREDICTFUN_API_KEY,
predictAccount: '0x…deposit address…',
signer: new LocalSigner(process.env.PREDICTFUN_PRIVATE_KEY!), // Privy key
});
// Or fully keyless against the BNB testnet:
const test = await PredictFunClient.create({ testnet: true });| Option | Type | Default | Notes |
|---|---|---|---|
| apiKey | string | — | Mainnet x-api-key (issued via predict.fun's Discord). Required for every mainnet endpoint; the testnet needs none. |
| testnet | boolean | false | Target api-testnet.predict.fun (BNB testnet, chain 97, keyless) — flips the base URL, RPC, collateral, and exchange contracts. |
| walletAddress | string | signer's address | Wallet for getBalance/getPositions/getPortfolioValue (read-only, keyed by address). |
| signer | Signer | — | Required for trading + getFills/order reads. A plain EOA must custody the USDT collateral (maker == signer); with predictAccount set it is the account's owner (Privy) key instead. JWT auth personal-signs (EIP-191), so the signer needs raw-digest capability — LocalSigner provides it. |
| predictAccount | string | — | Kernel smart-account address for an email/social sign-in account (the deposit address in predict.fun settings). Orders and the JWT then act as the account — maker == signer == predictAccount, Kernel-envelope signatures (signatureType stays 0; the exchange verifies via ERC-1271), and portfolio reads default to the account. |
| baseUrl | string | per network | REST base override. |
| rpcUrl | string | per network | BNB Chain JSON-RPC for the on-chain USDT balance. |
| collateralAddress | string | per network | Collateral token getBalance reads (18-decimal USDT). |
| timeoutMs / fetch / sleep / now | — | — | Same plumbing as the other venues. |
predict.fun is a BNB Chain fork of Polymarket's V1 CTF-exchange CLOB. Wallet
auth is a Bearer JWT the client bootstraps automatically (fetch login message →
EIP-191 personal_sign → POST /v1/auth), cached and re-derived once on a 401.
OpinionClient.create(options?)
Opinion Labs (opinion.trade) is an on-chain CLOB on BNB Chain (chainId 56),
collateralized in USDT. Reads need only an apiKey; trading uses a
two-key model plus an owner-EOA signer on an onboarded Gnosis Safe.
import { OpinionClient, LocalSigner } from 'prediction-market-sdk';
// Read-only:
const opinion = await OpinionClient.create({ apiKey: '…' });
// Trading (maker = the Safe, signed by the owner EOA, relayed by the builder):
const trading = await OpinionClient.create({
apiKey: '…', // per-user key: user-scoped reads + cancels
builderApiKey: '…', // mints keys, deploys the Safe, relays orders
signer: new LocalSigner('0x…'), // owner EOA
safeAddress: '0x…', // the deposit Safe; auto-resolved via the builder if omitted
});| Option | Type | Default | Notes |
|---|---|---|---|
| apiKey | string | — | Per-user key. Public market data works with any key; user-scoped reads (getPositions/getOpenOrders/getFills/getOrder) and cancelOrder need the caller's own per-user key. |
| builderApiKey | string | — | Builder key — mints per-user keys, deploys each user's Safe, and relays orders. Required for onboarding and placeOrder. |
| signer | Signer | — | Owner-EOA signer (new LocalSigner('0x…') or any external backend). Signs orders (sigType 2 / Gnosis Safe) and the enable-trading Safe tx. |
| safeAddress | string | resolved via builder getUser | The maker Safe (asset wallet) where funds live. |
| rpcUrl | string | https://bsc-dataseed.bnbchain.org | BSC JSON-RPC for the on-chain balance, Safe nonce, and fee rates. |
| chainId | number | 56 | EIP-712 chain id (BNB Chain). |
Orders are signed by the owner EOA but the maker is the Safe (sigType 2);
the minimum order value is 1.30 USDT. Onboarding is a separate, idempotent
three-step flow (Opinion-specific, not on the base contract): createUser()
(deploys the Safe, returns the per-user key once), enableTrading() (a
one-time gasless Safe tx approving the exchange), and getUser() (state). See
examples/opinionlabs/.
Method support matrix
Legend: ✅ available · 🔜 planned (not yet implemented — see Scope) · ❌ not supported. Every method returns a normalized SDK type, identical in shape across venues.
Market data
| Method | Description (returns) | Kalshi | Polymarket | predict.fun | Opinion Labs |
|---|---|---|---|---|---|
| getMarkets(query?) | Markets matching a query → Market[] | ✅ | ✅ | ✅ | ✅ |
| getMarket(id) | One market by id / conditionId → Market | ✅ | ✅ | ✅ numeric id only | ✅ |
| getTrendingMarkets(query?) | Most actively traded markets (24h volume) → Market[] | ✅ | ✅ | ✅ | ✅ |
| searchMarkets(text, query?) | Free-text market search → Market[] | ❌ no API¹ | 🔜 | 🔜 | ❌ no API |
| getEvents(query?) | Markets grouped into their event hierarchy → Event[] | ✅ | ✅ | ✅ categories | ✅ categorical |
| getTrendingEvents(query?) | Most actively traded events (24h volume) → Event[] | ✅ | ✅ | ✅ | ✅ |
| getOrderbook(marketId, outcomeId?) | One outcome's bids/asks → Orderbook | ✅ | ✅ | ✅ | ✅ |
| getTrades(marketId, opts?) | Public trade tape (all participants) → Trade[] | ✅ | ✅ | ✅ | ❌ no tape³ |
| getPriceHistory(marketId, outcomeId?, opts) | OHLC / price time-series → PricePoint[] | 🔜 | 🔜 | 🔜 | 🔜 |
| getResolution(marketId) | One market's resolution state (status / outcome / rules) → Resolution | ✅ | ✅ | ✅ | ✅ |
| resolutionPolicy() | Venue-static edge-case policy (forfeits) → ResolutionPolicy | ✅ | ✅ | ✅ 'unknown'² | ✅ |
¹ Kalshi exposes no free-text market-search endpoint, so searchMarkets cannot
be homogenized across venues yet and is intentionally unimplemented. Use
getMarkets / getEvents
with category to narrow results in the meantime.
² predict.fun documents no forfeit/postponement policy, so its
resolutionPolicy().forfeit is the honest 'unknown' rather than a guess —
see ForfeitPolicy.
³ Opinion Labs has no public trade tape, so getTrades throws a typed
NotSupportedError. Use getFills for your own
executions; a live tape is deferred to a future streaming surface.
Account & positions
| Method | Description (returns) | Kalshi | Polymarket | predict.fun | Opinion Labs |
|---|---|---|---|---|---|
| getBalance() | Free, withdrawable cash → Balance | ✅ needs credentials | ✅ on-chain USDC, needs walletAddress | ✅ on-chain USDT, needs walletAddress | ✅ on-chain USDT on the Safe |
| getPortfolioValue() | Total mark-to-market value → Money | ✅ computed (cash + positions) | ✅ data-api value, needs walletAddress | ✅ computed (cash + venue marks) | ✅ computed (cash + positions) |
| getPositions() | Open positions → Position[] | ✅ needs credentials | ✅ needs walletAddress | ✅ needs walletAddress | ✅ needs per-user apiKey |
| getFills(query?) | Your own executions (≠ public tape) → Fill[] | ✅ needs credentials | ✅ needs a signer + CLOB creds | ✅ needs a signer | ✅ needs per-user apiKey |
Orders
| Method | Description (returns) | Kalshi | Polymarket | predict.fun | Opinion Labs |
|---|---|---|---|---|---|
| getOpenOrders(marketId?) | Resting (open / partially filled) orders → Order[] | ✅ needs credentials | ✅ needs a signer + CLOB creds | ✅ needs a signer | ✅ needs per-user apiKey |
| getOrder(orderId) | A single order, any status → Order | ✅ needs credentials | ✅ needs a signer + CLOB creds | ✅ needs a signer | ✅ needs per-user apiKey |
| getOrderHistory(query?) | Terminal (filled / cancelled) orders → Order[] | ✅ needs credentials | ✅ needs a signer + CLOB creds | ✅ filled only⁴ | ✅ needs per-user apiKey |
| placeOrder(req) | Place one order, verified result → OrderResult | ✅ needs credentials | ✅ needs a signer + CLOB creds | ✅ needs a signer | ✅ needs builderApiKey + signer + Safe |
| placeOrders(reqs) | Place several orders (not atomic) → OrderResult[] | ✅ needs credentials | ✅ needs a signer + CLOB creds | ✅ needs a signer | ✅ needs builderApiKey + signer + Safe |
| modifyOrder(orderId, changes) | Amend price/size (PM: cancel+replace, id may change) → OrderResult | 🔜 | 🔜 | 🔜 | 🔜 |
| cancelOrder(orderId) | Cancel one order, verified → CancelResult | ✅ needs credentials | ✅ needs a signer + CLOB creds | ✅ off-chain removal⁵ | ✅ needs per-user apiKey |
| cancelAllOrders(marketId?) | Cancel all working orders (optionally per market) → CancelResult[] | ✅ needs credentials | ✅ needs a signer + CLOB creds | ✅ off-chain removal⁵ | ✅ needs per-user apiKey |
| estimateFees(req) | Maker + taker fee estimate for an order → FeeEstimate | ✅ | ✅ | ✅ per-market feeRateBps | ✅ on-chain rate |
⁴ predict.fun lists only OPEN and FILLED orders, so its getOrderHistory means
filled orders; cancelled/expired ones stay readable individually via
getOrder('0x…hash') but are not listable.
⁵ predict.fun cancellation removes the order from the book off-chain; the
signed order remains technically valid on-chain until its expiration (a full
on-chain cancelOrders transaction is out of the SDK's scope).
Streaming (returns a Subscription handle with .close())
| Method | Description (returns) | Kalshi | Polymarket | predict.fun | Opinion Labs |
|---|---|---|---|---|---|
| subscribeOrderbook(marketId, outcomeId?, cb) | Live orderbook updates → Subscription | 🔜 | 🔜 | 🔜 | 🔜 |
| subscribeTrades(marketId, cb) | Live public trade feed → Subscription | 🔜 | 🔜 | 🔜 | 🔜 |
| subscribeTicker(marketId, cb) | Live price / ticker updates → Subscription | 🔜 | 🔜 | 🔜 | 🔜 |
| subscribeOrders(cb) | Live updates to your orders → Subscription | 🔜 | 🔜 | 🔜 | 🔜 |
| subscribeFills(cb) | Live updates as your orders fill → Subscription | 🔜 | 🔜 | 🔜 | 🔜 |
Cross-venue (on a separate MultiVenueClient facade, not the per-venue client)
| Method | Description (returns) | Kalshi | Polymarket | predict.fun | Opinion Labs |
|---|---|---|---|---|---|
| getBestPrice(refs) | Best bid/ask across explicit venue refs → best-price result | 🔜 | 🔜 | 🔜 | 🔜 |
getBalance means the same thing on every venue (free cash); getPortfolioValue
means total mark-to-market value on all of them. Pick the one you want — no venue branching.
The 🔜 rows are on the roadmap and described in Scope; the live contract
today is the methods below.
The client contract
Every client exposes one read-only property, twenty async methods, and one
synchronous method (resolutionPolicy).
abstract class PredictionMarketClient {
readonly venue: Venue; // 'polymarket' | 'kalshi' | 'predictfun' | 'opinionlabs'
getMarkets(query?: MarketQuery): Promise<Market[]>;
getMarket(id: string): Promise<Market>;
getTrendingMarkets(query?: TrendingQuery): Promise<Market[]>; // activity-ranked discovery
getEvents(query?: EventQuery): Promise<Event[]>;
getTrendingEvents(query?: TrendingQuery): Promise<Event[]>; // activity-ranked events
getOrderbook(marketId: string, outcomeId?: string): Promise<Orderbook>;
getTrades(marketId: string, opts?: TradesQuery): Promise<Trade[]>;
getResolution(marketId: string): Promise<Resolution>;
resolutionPolicy(): ResolutionPolicy; // synchronous; venue-static
getBalance(): Promise<Balance>; // free cash
getPortfolioValue(): Promise<Money>; // total mark-to-market value
getPositions(): Promise<Position[]>;
getFills(query?: FillsQuery): Promise<Fill[]>; // your own executions
getOpenOrders(marketId?: string): Promise<Order[]>; // working orders
getOrder(orderId: string): Promise<Order>;
getOrderHistory(query?: OrderHistoryQuery): Promise<Order[]>; // terminal orders
placeOrder(req: PlaceOrderRequest): Promise<OrderResult>;
placeOrders(reqs: readonly PlaceOrderRequest[]): Promise<OrderResult[]>;
cancelOrder(orderId: string): Promise<CancelResult>;
cancelAllOrders(marketId?: string): Promise<CancelResult[]>;
estimateFees(req: PlaceOrderRequest): Promise<FeeEstimate>;
}venue
A readonly string literal: 'polymarket', 'kalshi', 'predictfun', or
'opinionlabs'.
Present on the client and stamped onto every normalized object it returns.
getMarkets(query?): Promise<Market[]>
Returns an array of Market. Empty array if none match.
query (MarketQuery, all optional):
| Field | Type | Effect |
|---|---|---|
| status | MarketStatus | Filter by lifecycle state (see per-venue mapping below). |
| category | string | Filtered client-side on the normalized Market.category. |
| limit | number | Max markets to request. |
| cursor | string | Pagination token. Kalshi: opaque cursor. Polymarket: numeric offset. |
Per-venue:
- Kalshi →
GET /markets.statusmapsopen→open,closed→closed,resolved→settled;cancelledis ignored (no filter sent). - Polymarket → Gamma
GET /markets.statusmapsopen→active=true&closed=false,closed/resolved→closed=true;cancelledsends no status filter.cursoris sent asoffset. - predict.fun →
GET /v1/markets(first/aftercursor paging). The server filter only knowsOPEN/RESOLVED, sostatusis sent when it maps and always re-applied client-side (makingclosed/cancelledexact too). Markets carry no venue category field, so acategoryfilter matches nothing.
getMarket(id): Promise<Market>
Returns a single Market.
- Kalshi:
idis the market ticker (e.g.KXTEMPNYC-…). - Polymarket:
idis the Gamma numeric id (e.g."540817") or a0x…conditionId (resolved via Gamma'scondition_idsfilter). This lets aPosition/Traderound-trip straight back to its market. - predict.fun:
idis the venue numeric id (e.g."425") only — the venue exposes no conditionId lookup route, so a0x…id throwsValidationErrorcode: 'UNSUPPORTED_ID'. Join throughMarket.conditionIdand key follow-up calls byMarket.id.
The returned Market carries both id and conditionId — see the
venue cheat-sheet on joining positions/trades to markets.
Throws NotFoundError if the venue returns 404.
getTrendingMarkets(query?): Promise<Market[]>
Returns Markets ranked by recent trading activity (24h volume
on both venues), restricted to tradeable ones — open, nonzero volume, and at
least one outcome marked strictly inside (0, 1). This is the method to build a
"browse markets" view on: plain getMarkets({ status: 'open' }) is
creation-ordered and (on Kalshi especially) dominated by zero-volume
auto-generated markets. A best-effort discovery surface — each venue's own
activity ranking — not a precise volume metric.
query (TrendingQuery, all optional): limit (default
25) and category (filtered client-side on Market.category, like
getMarkets).
Per-venue:
- Kalshi → the documented
/marketsendpoint has no working sort, so the ranking comes from Kalshi's own (undocumented) frontend search API —GET /v1/search/series?order_by=trending(a validated enum; verified live 2026-06-12). It is used only to pick candidate tickers; the markets are then batch-fetched via the documentedGET /markets?tickers=…, ranked byvolume_24h_fp, filtered, and cut tolimit. Being a frontend API it could change without notice; a failure surfaces as a normalVenueError. - Polymarket → Gamma
GET /markets?order=volume24hr&ascending=false&active=true&closed=false(native server-side ranking), over-fetched 2× then filtered. - predict.fun →
GET /v1/markets?sort=VOLUME_24H_DESC&status=OPEN(native server-side ranking), over-fetched 2× then filtered. Markets expose no volume field, so the tradeable filter keeps open markets with a live(0, 1)mark and trusts the venue's volume ordering.
getEvents(query?): Promise<Event[]>
Returns an array of Event — the venue's discovery
hierarchy folded into one homogeneous shape. Each Event embeds its fully
normalized constituent Markets (no follow-up calls needed), and the
parent grouping (where one exists above the event level) rides on seriesKey.
query (EventQuery, all optional): status,
category (filtered client-side, like getMarkets), limit, cursor, and
seriesKey. limit defaults to 50 on both venues when omitted — each event
embeds its full market list, so the call is a single bounded page rather than an
unbounded fetch. Pass a larger limit (or page via cursor) for more.
- Kalshi →
GET /events?with_nested_markets=true(one page).seriesKeymaps to theseries_tickerfilter (the efficient targeted pull) andEvent.seriesKeyis the series ticker (e.g.KXNBAGAME). - Polymarket → Gamma
GET /eventswithstatus→active/closedfilters.Event.seriesKeyis the series slug when the event belongs to one; otherwise absent.seriesKeyin the query is ignored (Gamma has no series filter here). - predict.fun →
GET /v1/categories— a category is the venue's event grouping (one election, one game) with its markets nested in full.Event.idis the category slug,Event.categorythe venue tag (e.g.Politics),seriesKeytheparentSlugwhen present (queryseriesKeyis ignored).
getTrendingEvents(query?): Promise<Event[]>
Returns Events ranked by recent trading activity
(24h volume on both venues), restricted to events with at least one tradeable
market (open, traded, marked strictly inside (0, 1)). The event-level analog
of getTrendingMarkets.
query (TrendingQuery, all optional): limit (default
25) and category (filtered client-side on Event.category).
Per-venue:
- Kalshi → candidate event tickers come from the same frontend
GET /v1/search/series?order_by=trending; each is then fetched through the documentedGET /events/{ticker}?with_nested_markets=true(one request per event — Kalshi has no batch event endpoint, so the fan-out is capped) and ranked by its summed 24h market volume. Same frontend-API caveat asgetTrendingMarkets. - Polymarket → Gamma
GET /events?order=volume24hr&ascending=false&active=true&closed=false(native server-side ranking), over-fetched 2× then filtered. - predict.fun →
GET /v1/categories?sort=VOLUME_24H_DESC&status=OPEN(native server-side ranking), over-fetched 2× then filtered on live-mark markets (seegetTrendingMarkets).
getOrderbook(marketId, outcomeId?): Promise<Orderbook>
Returns an Orderbook for one outcome. bids are sorted
descending by price, asks ascending; prices are implied probabilities in
[0, 1].
outcomeId:
- Kalshi:
'YES'or'NO'(case-insensitive); defaults to'YES'. Any other value throwsValidationError. The requested side's resting orders becomebids; the opposite side is converted toasksvia the1 − pricecomplement. - Polymarket: the CLOB token id (this is
Outcome.id). If omitted, the client fetches the market and usesoutcomes[0].id(one extra request). - predict.fun: the ERC-1155 token id (
Outcome.id). The venue serves one book per market, quoted on its primary (indexSet === 1) outcome — the default whenoutcomeIdis omitted; requesting the complement outcome returns the mirrored1 − pricebook (like Kalshi's NO side).
Orderbook.timestamp:
- Kalshi: the client's clock at fetch time (the HTTP orderbook carries no timestamp).
- Polymarket: the venue's book timestamp when present, else the client's clock.
- predict.fun: the venue's
updateTimestampMswhen present, else the client's clock.
getTrades(marketId, opts?): Promise<Trade[]>
Returns an array of Trade — the public trade tape for the market
(all participants, not just you). Trade.price is in [0, 1].
opts (TradesQuery, all optional):
| Field | Type | Kalshi | Polymarket | predict.fun |
|---|---|---|---|---|
| limit | number | sent as limit | sent as limit | sent as first |
| since | string (ISO 8601) | converted to min_ts (unix seconds) | ignored | filtered client-side |
| cursor | string | sent as cursor | sent as offset | sent as after |
Per-venue:
Trade.side means the taker's direction relative to outcomeId: 'buy' = the
taker acquired that outcome, 'sell' = disposed of it.
- Kalshi →
GET /markets/trades?ticker=….sideis always'buy'because Kalshi models selling YES as buying NO — the tape only ever shows a taker acquiring the outcome named byoutcomeId('YES'/'NO').marketIdandconditionIdare both the ticker. - Polymarket → data-api
GET /trades?market=<conditionId>. IfmarketIdstarts with0xit is used as the conditionId directly; otherwise the client resolves it viagetMarket(one extra request).sideis the real'buy'/'sell';idis the transaction hash;marketIdandconditionIdare both the conditionId,outcomeIdthe token id. - predict.fun →
GET /v1/orders/matches?marketId=…(settled order-match events, newest first). The trade is the taker's slice of each match:sidefrom the taker quote (Bid→buy,Ask→sell),idthe settlement id, prices/sizes normalized from the venue's 1e18 wei strings.
getResolution(marketId): Promise<Resolution>
Returns one market's Resolution
state: its status, the winning resolvedOutcomeId (once settled), resolveTime,
and rules/source where the venue exposes them. This is per-market data —
for the venue-static edge-case policy use resolutionPolicy().
- Kalshi → read from
GET /markets/{ticker}.resolvedOutcomeIdis'YES'/'NO'from the settledresult;rulesfromrules_primary;sourcefromsettlement_sources.marketIdaccepts the ticker. - Polymarket → read from the Gamma market (
marketIdaccepts a Gamma id or a0x…conditionId). Once resolved,resolvedOutcomeIdis the token id whoseoutcomePricesentry is ~1;rulesis the market description;sourceisresolutionSource. - predict.fun → read from
GET /v1/markets/{id}(numeric id).resolvedOutcomeIdis the winning outcome's token id (the venue marks outcomesWON/LOST);rulesis the market description.
resolutionPolicy(): ResolutionPolicy
Synchronous (venue-static, no I/O). Returns the venue's
ResolutionPolicy for edge
cases the two venues resolve differently — most importantly esports/tournament
forfeits. Kalshi pays out the official recorded result (forfeit: 'tournament_result');
Polymarket voids 50/50 (forfeit: 'void_50_50'); predict.fun documents no policy,
so it reports the honest forfeit: 'unknown' — treat its edge cases as unpriced
risk and read the per-market Resolution.rules. Surfacing this prevents the class
of cross-venue divergence that silently turns a naive arb into a loss (HANDOFF §4.4).
getBalance(): Promise<Balance>
Returns a Balance — free, withdrawable cash on both venues.
available and total are both Money and equal in v1.
- Kalshi: the account cash balance (
GET /portfolio/balance). Requires credentials. - Polymarket: free cash, read on-chain via
eth_call balanceOfagainst the Polymarket collateral tokenpUSD(0xc011a7e1…) on Polygon (usesrpcUrl). Polymarket migrated off bridged USDC.e, which now reads 0. RequireswalletAddress(the proxy wallet that custodies the collateral), else throwsValidationErrorwithcode: 'NO_WALLET'. A failed RPC call throws aVenueErrorwithcode: 'RPC_ERROR'. - predict.fun: free cash, read on-chain via
eth_call balanceOfagainst BNB-chain USDT (18 decimals, scaled to canonical micro-dollars; testnet uses the venue's mock USDT). RequireswalletAddress(defaults to the signer's address); sameNO_WALLET/RPC_ERRORerrors as Polymarket.
getPortfolioValue(): Promise<Money>
Returns total mark-to-market portfolio value (cash + open positions) on both
venues, as normalized USD Money (read .value for dollars).
- Kalshi: computed as cash + Σ(position size × the outcome's current price). Kalshi exposes no single portfolio figure, so this is a best-effort estimate that issues one market fetch per open position. Requires credentials.
- Polymarket: on-chain
pUSDcash + data-apiGET /value(the venue's positions mark-to-market, which excludes cash). RequireswalletAddress. - predict.fun: on-chain USDT cash + Σ of the venue's own per-position
valueUsdmarks. RequireswalletAddress(defaults to the signer's address).
getPositions(): Promise<Position[]>
Returns an array of Position. Zero-size positions are omitted.
Paginated internally.
- Kalshi: requires credentials.
marketIdandconditionIdare both the ticker;outcomeIdis'YES'/'NO';realizedPnlis USD when present;currentPriceandunrealizedPnlare not set. - Polymarket: requires
walletAddress(elseValidationErrorcode: 'NO_WALLET').marketIdandconditionIdare both the conditionId;outcomeIdis the token id;avgEntryPriceandcurrentPriceare in[0, 1];realizedPnlandunrealizedPnlare USDMoney. - predict.fun: requires
walletAddress(an API-key-only read — no signer needed).GET /v1/positions/{address}, cursor-paged.marketIdis the numeric id,conditionIdrides from the embedded market,outcomeIdis the token id; sizes normalize from 1e18 wei strings;currentPriceis the outcome's best bid;unrealizedPnlfrom the venue'spnlUsd.
getFills(query?): Promise<Fill[]>
Returns an array of Fill — your own executions,
each linked to its order with your role (maker/taker). This is not the
public getTrades tape. query (all optional): marketId, orderId, since
(ISO 8601, exclusive), limit, cursor.
- Kalshi: requires credentials.
GET /portfolio/fills, cursor-paged. Kalshi does not report a per-fillfeeon this payload, so it is omitted (useestimateFees). - Polymarket: requires a
signer+ CLOB credentials (elseValidationErrorcode: 'NO_SIGNER').GET /data/trades(L2-authed);feeis derived fromfee_rate_bps(zero today). - predict.fun: requires a
signer(elseValidationErrorcode: 'NO_SIGNER').GET /v1/orders/matches?signerAddress=…— yourrole/side/orderIdcome from whether you were the match's taker or one of its makers; a share-denominated fee (type: 'SHARES') is valued at the execution price into USDMoney.
getOpenOrders(marketId?): Promise<Order[]>
Returns an array of Order — working (open / partially
filled) orders. The complement of getOrderHistory.
- Kalshi: requires credentials.
GET /portfolio/orders?status=resting, optionally filtered tomarketId(ticker). Paginated internally. - Polymarket: requires a
signer+ CLOB credentials (elseValidationErrorcode: 'NO_SIGNER').GET /data/orders(L2-authed), filtered to working orders;marketId(Gamma id or0x…conditionId) scopes by market. - predict.fun: requires a
signer.GET /v1/orders?status=OPEN(JWT-authed), cursor-paged;marketId(numeric id) filters client-side.Order.idis the venue's numeric order id (the0x…order hash rides onraw).
getOrder(orderId): Promise<Order>
Returns one Order by id, in any state.
- Kalshi: requires credentials.
GET /portfolio/orders/{id}. - Polymarket: requires a
signer+ CLOB credentials.GET /data/order/{id}. - predict.fun: requires a
signer. A0x…order hash readsGET /v1/orders/{hash}directly (works in any state); a numeric id is looked up by walking the OPEN and FILLED lists — cancelled/expired orders are only reachable by hash.
getOrderHistory(query?): Promise<Order[]>
Returns terminal (filled / cancelled) Orders — the
complement of getOpenOrders. query
(all optional): marketId, since, limit, cursor.
- Kalshi: requires credentials. Walks
GET /portfolio/ordersand keeps the non-working orders. - Polymarket: requires a
signer+ CLOB credentials. ReadsGET /data/ordersand keeps the terminal ones. - predict.fun: requires a
signer. WalksGET /v1/orders?status=FILLED— the venue lists only OPEN and FILLED, so history here means filled orders (cancelled/expired ones stay readable individually viagetOrder('0x…hash')).
placeOrder(req): Promise<OrderResult>
Places a single order from a normalized PlaceOrderRequest
and returns a verified OrderResult — status/filledSize
already resolve each venue's misleading immediate response, so you can trust
them without a follow-up read.
const result = await client.placeOrder({
marketId: '…', // Kalshi ticker, or PM Gamma id / 0x… conditionId
outcomeId: 'YES', // Kalshi: 'YES'/'NO'; Polymarket: the CLOB token id
side: 'buy',
price: 0.62, // implied probability in [0, 1]
size: 10, // contracts (Kalshi) / shares (Polymarket)
tif: 'ioc', // default: 'ioc' Kalshi, 'fok' Polymarket
});- Kalshi: requires credentials. Uses the V2 endpoint
POST /portfolio/events/orders(the legacyPOST /portfolio/ordersis deprecated and returns410). V2 quotes a single YES-leg book:sideisbid(buy YES) /ask(sell YES), so aNOorder is restated as its YES complement — buy NO @ p becomes an ask at1 − p. Price is a fixed-point dollar string in[0,1],counta fixed-point quantity string.tifmapsioc→immediate_or_cancel,gtc→good_till_canceled,fok→fill_or_kill(V2 supports fill-or-kill;self_trade_prevention_typedefaults totaker_at_cross). The V2 response is flat withfill_count/remaining_count(nostatusfield); when both are zero the client re-reads viaGET /portfolio/orders/{id}to confirm the outcome (guarding the legacyfill_count="0"quirk) sofilledSize/statusare correct. - Polymarket: requires a
signer(elseValidationErrorcode: 'NO_SIGNER'); the CLOB L2 credentials are derived from the signer and cached automatically on first use, so passingclobApiKey/clobSecret/clobPassphraseis optional. The order is EIP-712-signed locally (secp256k1), submitted with L2 HMAC auth (tiffok→FOK,ioc→FAK,gtc→GTC), then polled until terminal (Polymarket's immediate status is usuallylive/delayed). A CLOB rejection throwsVenueErrorcode: 'ORDER_REJECTED'. A fillable order also needs on-chain USDC/CTF allowances set (operator prerequisite). UsePolymarketClient.buildSignedOrder(req)to build + sign without submitting (a dry-run of the signing path). - predict.fun: requires a
signer(elseValidationErrorcode: 'NO_SIGNER'); the Bearer JWT is bootstrapped and cached automatically. The market is fetched fresh (a non-OPENtradingStatusthrowsMARKET_NOT_OPEN), the EIP-712 V1 order is signed against the exchange contract selected by the market's(isNegRisk, isYieldBearing)flags, and submitted asstrategy: 'LIMIT'(tiffok→ +isFillOrKill, the default;gtcrests;iocthrowsUNSUPPORTED_TIF— the venue's MARKET-order semantics are unverified). Every order signs a 30-day expiration (the venue rejectsexpiration: 0— minimum 2 minutes out) and must have a value of at least $0.90 (venue minimum, enforced at submit). The submit response carries no status/fill, so the client verifies by re-reading the order before returning. Balance is not checked at submit: an unfunded maker's order is accepted and then auto-cancelled by the venue moments later — a lasting, fillable order needs USDT in the wallet and an on-chain allowance for the exchange (operator prerequisite; the four exchange addresses are exported asPREDICTFUN_EXCHANGES/PREDICTFUN_TESTNET_EXCHANGES).
placeOrders(reqs): Promise<OrderResult[]>
Places several orders, returning one verified OrderResult
per request in input order. Not atomic — neither venue offers an
all-or-nothing batch, so a later failure does not roll back earlier placements.
Inspect each result. Same credentials and per-venue behavior as
placeOrder.
cancelOrder(orderId): Promise<CancelResult>
Cancels one order and returns a verified CancelResult —
cancelled reflects the venue's settled view, the same trust contract as
OrderResult.
- Kalshi: requires credentials.
DELETE /portfolio/orders/{id}; the response carries the post-cancel order state. - Polymarket: requires a
signer+ CLOB credentials.DELETE /orderwith the order id;cancelledis set when the id appears in the venue'scanceledlist. - predict.fun: requires a
signer.POST /v1/orders/remove— an off-chain book removal: matching stops immediately, but the signed order stays technically valid on-chain until its expiration (full invalidation needs an on-chaincancelOrderstransaction, out of the SDK's scope). Anoopresponse (already filled/removed) is resolved by re-reading the order for its truestatus.
cancelAllOrders(marketId?): Promise<CancelResult[]>
Cancels every working order, optionally scoped to one marketId. Reads the open
orders first, then cancels each — one CancelResult per order.
Same credentials and caveats as cancelOrder on every venue (predict.fun
batches ids through POST /v1/orders/remove, ≤100 per call).
estimateFees(req): Promise<FeeEstimate>
Returns a FeeEstimate with both the maker and taker fee for
a PlaceOrderRequest-shaped
order — the role is unknowable until execution, so pick the field for the role
you expect. Computed from a pure formula today (no network call), but async to
leave room for venues that publish a fee schedule.
- Kalshi:
rate × P(1−P) × contracts, rounded up to the cent — taker0.07, maker0.0175(HANDOFF §3.9). The price-shaped fee is materially more expensive at mid-prices than near the extremes. - Polymarket: zero for both roles today (HANDOFF §2.8); gas is paid by the relayer. The hook stays in case that changes.
- predict.fun: per-market — one market fetch reads its
feeRateBps(2% on testnet), applied asrate × min(p, 1−p) × size(the CTF-exchange formula, symmetric around 0.5). Takers only: makers pay zero (verified on the live match tape of both networks), somakerFeeis always0.
Types
All types are exported from the package root. Every price/probability/
avgEntryPrice is an implied probability in [0, 1]. Timestamps are ISO 8601
strings.
Venue, Side, Currency
type Venue = 'polymarket' | 'kalshi' | 'predictfun' | 'opinionlabs';
type Side = 'buy' | 'sell';
type Currency = 'USD'; // all money is normalized to USD across venuesMoney
All monetary values are normalized to USD and homogeneous across venues.
Read value (dollars) for display/comparison; amount is the same figure as a
lossless integer in micro-dollars (decimals is always 6).
interface Money {
amount: bigint; // exact integer micro-dollars (decimals = 6)
currency: Currency; // always 'USD'
decimals: number; // always 6
value: number; // the amount in dollars — use this
}Example: $20.00 → { amount: 20_000_000n, currency: 'USD', decimals: 6, value: 20 }.
Kalshi cents and Polymarket USDC are both converted into this shape, so
a.value and b.value (or a.amount and b.amount) are directly comparable.
Market
interface Market {
venue: Venue;
id: string;
conditionId?: string; // settlement/join key — Kalshi: == id (ticker) · Polymarket: 0x… conditionId
slug?: string;
title: string; // distinct per-market label (Kalshi folds yes_sub_title in — see table)
description?: string;
status: MarketStatus; // 'open' | 'closed' | 'resolved' | 'cancelled'
outcomes: Outcome[];
closeTime?: string; // ISO 8601 — expected trading end
resolveTime?: string; // ISO 8601 — settlement time, present only once resolved
volume?: Money;
category?: string;
url?: string;
raw?: unknown; // the original venue payload
}Field population by venue:
| Field | Kalshi | Polymarket | predict.fun |
|---|---|---|---|
| id | ticker | Gamma numeric id | venue numeric id |
| conditionId | ticker (== id) | 0x… conditionId (≠ id) | 0x… conditionId (≠ id) |
| slug | — | set when present | the category slug |
| title | title + yes_sub_title folded in (e.g. England vs Ghana Winner? — England), so multi-outcome sub-markets are distinct; bare title/ticker when no sub-title | question or id | question (distinct per market; the venue's short title like <$2,500 rides on raw) |
| description | rules_primary (the resolution prose), else subtitle | set when present | set when present (the resolution prose) |
| outcomes | exactly 2 (YES/NO) | parsed from outcomes/outcomePrices/clobTokenIds | 2, ids = ERC-1155 token ids, names verbatim (Yes/No, Up/Down, …) |
| closeTime | expected_expiration_time or close_time | endDate | — (no market-level close time; the category has endsAt) |
| resolveTime | settled_time (once resolved) | closedTime (once resolved) | — (venue exposes none) |
| volume | volume_fp as USD Money (the figure Kalshi's frontend shows in dollars) | volumeNum as USD Money | from stats when populated (null on testnet) |
| category | when present | when present (often absent) | — (categories are the event grouping, not a topic) |
| url | — (no verified Kalshi web-URL format) | https://polymarket.com/market/<slug> (best-effort) | — (no verified URL format) |
Outcome
interface Outcome {
id: string; // Kalshi: 'YES'|'NO' · Polymarket: CLOB token id
name: string; // homogeneous binary label: 'Yes' / 'No' on both venues
// (Kalshi selections like 'England' ride on raw, and are
// folded into Market.title — not into the outcome name)
probability: number; // mark-price implied probability in [0, 1]
raw?: unknown;
}MarketStatus
type MarketStatus = 'open' | 'closed' | 'resolved' | 'cancelled';- Kalshi:
active/initialized → open,closed → closed,settled → resolved(unknown →open). - Polymarket:
closed → resolved,archived → closed,active → open(elseclosed).cancelledis not emitted by either venue.
MarketQuery
interface MarketQuery {
status?: MarketStatus;
category?: string;
limit?: number;
cursor?: string;
}TrendingQuery
interface TrendingQuery {
limit?: number; // max markets to return; defaults to 25
category?: string; // filtered client-side on Market.category, like MarketQuery
}The query for getTrendingMarkets.
No status/cursor: trending is always open markets, and it is a bounded
"what's hot" page, not an enumeration.
Event and EventQuery
interface Event {
venue: Venue;
id: string;
title: string;
slug?: string;
category?: string;
markets: Market[]; // fully normalized, embedded
seriesKey?: string; // parent grouping (Kalshi series ticker · Polymarket series slug)
raw?: unknown;
}
interface EventQuery {
status?: MarketStatus;
category?: string;
limit?: number; // max events to return; defaults to 50 on both venues when omitted
cursor?: string;
seriesKey?: string; // restrict to one series (Kalshi only; ignored elsewhere)
}The hierarchy is homogenized to one level: an Event groups markets, and a
shared seriesKey is the only thing that links events into a series — there is
no separate getSeries.
| Field | Kalshi | Polymarket | predict.fun |
|---|---|---|---|
| id | event ticker | Gamma event id | category slug |
| seriesKey | series ticker (e.g. KXNBAGAME) | series slug when present, else — | parentSlug when present, else — |
| markets | nested via with_nested_markets=true | nested in the Gamma event | nested in the category |
Resolution, ResolutionPolicy, and ForfeitPolicy
interface Resolution { // per-market — getResolution(marketId)
venue: Venue;
marketId: string;
conditionId?: string;
status: MarketStatus;
resolvedOutcomeId?: string; // the winning Outcome.id, once settled
resolveTime?: string; // ISO 8601, once settled
rules?: string; // resolution criteria, when exposed
source?: string; // settlement source / oracle, when exposed
raw?: unknown;
}
interface ResolutionPolicy { // venue-static — resolutionPolicy()
venue: Venue;
forfeit: ForfeitPolicy; // how a tournament forfeit resolves
postponement?: string; // free-form; undefined until verified
notes?: string;
}
type ForfeitPolicy = 'void_50_50' | 'tournament_result' | 'unknown';Resolution is per-market state; ResolutionPolicy is a venue constant that
does not vary per market (hence resolutionPolicy() is synchronous). The
forfeit value is pinned by a real cross-venue divergence (HANDOFF §4.4):
Kalshi tournament_result (pays the recorded result) vs Polymarket void_50_50
(refunds); predict.fun documents no policy, so it reports 'unknown' — an
explicit "we refuse to guess", meaning its forfeits carry risk you must assess
from the per-market rules. postponement is left undefined rather than
guessed — it ships only once verified against a real settlement.
Orderbook and OrderbookLevel
interface OrderbookLevel {
price: number; // implied probability in [0, 1]
size: number; // resting size in contract units
}
interface Orderbook {
venue: Venue;
marketId: string;
outcomeId: string;
bids: OrderbookLevel[]; // sorted descending by price
asks: OrderbookLevel[]; // sorted ascending by price
timestamp: string; // ISO 8601
}Trade and TradesQuery
interface Trade {
venue: Venue;
id: string; // Kalshi: trade id · Polymarket: transaction hash
marketId: string; // Kalshi: ticker · Polymarket: conditionId
conditionId: string; // settlement/join key (== Market.conditionId)
outcomeId: string;
side: Side; // taker's direction on outcomeId — Kalshi: always 'buy'
price: number; // [0, 1]
size: number;
timestamp: string; // ISO 8601
}
interface TradesQuery {
limit?: number;
since?: string; // ISO 8601 lower bound — Kalshi only (ignored by Polymarket)
cursor?: string;
}Balance
interface Balance {
venue: Venue;
available: Money; // free cash on both venues (USD)
total: Money; // equals `available` for both venues in v1
}Position
interface Position {
venue: Venue;
marketId: string; // Kalshi: ticker · Polymarket: conditionId
conditionId: string; // settlement/join key (== Market.conditionId)
outcomeId: string;
size: number;
avgEntryPrice: number; // [0, 1]
currentPrice?: number; // [0, 1] — Polymarket only
realizedPnl?: Money; // USD (both venues when present)
unrealizedPnl?: Money; // Polymarket only (USD)
}Order and OrderStatus
type OrderStatus = 'open' | 'partially_filled' | 'filled' | 'cancelled';
interface Order {
venue: Venue;
id: string;
marketId: string;
conditionId: string; // settlement/join key (== Market.conditionId)
outcomeId: string;
side: Side;
price: number; // limit price, [0, 1]
size: number; // filled + remaining
filled: number;
status: OrderStatus;
createdAt: string; // ISO 8601
}Both venues return Orders — from getOpenOrders (working), getOrder (any
state), and getOrderHistory (terminal). On Polymarket these read the
authenticated CLOB, so they need a signer + CLOB credentials.
Fill, FillRole, and FillsQuery
The normalized outputs of getFills — your own
executions, distinct from the public getTrades tape.
type FillRole = 'maker' | 'taker';
interface Fill {
venue: Venue;
id: string; // venue fill id (Kalshi trade_id · Polymarket trade id)
orderId: string; // the order this execution belongs to
marketId: string; // Kalshi: ticker · Polymarket: conditionId
conditionId: string; // settlement/join key (== Market.conditionId)
outcomeId: string;
side: Side; // your direction on outcomeId
price: number; // [0, 1]
size: number; // contracts (Kalshi) / shares (Polymarket)
role: FillRole;
fee?: Money; // present when the venue reports a per-fill fee
timestamp: string; // ISO 8601
}
interface FillsQuery {
limit?: number;
since?: string; // ISO 8601 lower bound (exclusive)
cursor?: string;
marketId?: string; // Kalshi ticker / Polymarket conditionId
orderId?: string;
}OrderHistoryQuery (for getOrderHistory)
has limit, since, cursor, and marketId.
PlaceOrderRequest, OrderResult, and TimeInForce
The normalized inputs/outputs of placeOrder.
type TimeInForce = 'ioc' | 'fok' | 'gtc';
interface PlaceOrderRequest {
marketId: string; // Kalshi ticker · Polymarket Gamma id / 0x… conditionId
outcomeId: string; // Kalshi: 'YES'/'NO' · Polymarket: CLOB token id
side: Side; // 'buy' | 'sell'
price: number; // implied probability in [0, 1]
size: number; // contracts (Kalshi) / shares (Polymarket)
tif?: TimeInForce; // default: 'ioc' Kalshi, 'fok' Polymarket
clientOrderId?: string;
}
interface OrderResult {
venue: Venue;
id: string;
status: OrderStatus; // verified — venue's misleading immediate state resolved
filledSize: number; // contracts/shares actually filled
raw: unknown; // final venue payload (escape hatch)
}CancelResult
The normalized output of cancelOrder
and cancelAllOrders. cancelled
is verified — the same trust contract as OrderResult.
interface CancelResult {
venue: Venue;
orderId: string;
cancelled: boolean; // true once the order is no longer working on the book
status: OrderStatus; // resulting order state
raw: unknown; // venue payload (escape hatch)
}FeeEstimate
The normalized output of estimateFees.
Both roles are returned because the role is unknowable until execution.
interface FeeEstimate {
venue: Venue;
takerFee: Money; // if you cross the spread (take liquidity)
makerFee: Money; // if you rest and are filled by another taker
raw?: unknown; // formula inputs / venue payload
}Errors
Every error thrown by the SDK is an instance of PredictionMarketError
(subclass of Error). All carry venue and an optional code.
class PredictionMarketError extends Error {
venue: Venue;
code: string | undefined;
}| Class | When | Extra fields |
|---|---|---|
| AuthError | 401 / 403, or missing/invalid credentials | — |
| RateLimitError | 429 | retryAfterMs?: number |
| NotFoundError | 404 | — |
| NetworkError | transport failure or timeout (code: 'TIMEOUT' / 'NETWORK') | — |
| ValidationError | bad input or 4xx client error; also code: 'NO_WALLET' (PM portfolio), 'NO_SIGNER' (PM order placement / authenticated order reads without key+CLOB creds), 'INVALID_PRICE'/'INVALID_SIZE'/'UNSUPPORTED_TIF' (bad order) | — |
| VenueError | unrecognized ≥500 response; also code: 'ORDER_REJECTED' (PM CLOB rejection), 'RPC_ERROR' (PM on-chain balance) | status?: number, body?: unknown |
| PredictionMarketError | base class of all of the above | — |
import { NotFoundError, RateLimitError } from 'prediction-market-sdk';
try {
await kalshi.getMarket('does-not-exist');
} catch (err) {
if (err instanceof NotFoundError) { /* … */ }
if (err instanceof RateLimitError) { await wait(err.retryAfterMs ?? 1000); }
}The shared HTTP layer retries idempotent requests up to 3 attempts on
429/500/502/503/504, honoring Retry-After, with a 30s default timeout.
Venue cheat-sheet
| | Kalshi | Polymarket | predict.fun |
|---|---|---|---|
| Market.id | ticker | Gamma numeric id | venue numeric id |
| conditionId (everywhere) | ticker (== id) | 0x… conditionId (≠ id) | 0x… conditionId (≠ id) |
| Outcome.id | 'YES' / 'NO' | CLOB token id | ERC-1155 token id |
| Orderbook outcomeId | 'YES' / 'NO' (default YES) | CLOB token id | token id (defaults to the primary outcome) |
| Position/Trade marketId | ticker | conditionId (≠ Market.id) | venue numeric id (== Market.id) |
| getBalance | cash | on-chain pUSD cash | on-chain USDT cash (18-dp) |
| getPortfolioValue | cash + positions (computed) | data-api value | cash + venue marks (computed) |
| Money | USD Money (.value = dollars) | USD Money (.value = dollars) | USD Money (USDT ≈ USD) |
| Auth for market data | none | none | mainnet API key (testnet: none) |
| Auth for portfolio | API key + PKCS#8 key | wallet address only | wallet address (+ API key on mainnet) |
| placeOrder | API key + PKCS#8 key | secp256k1 key + CLOB L2 creds | secp256k1 key (JWT auto-derived) |
Joining positions/trades to markets: every
Market,Position,Trade, andOrdercarries aconditionId— the universal join key. Matchposition.conditionId === market.conditionIdon any venue (no branching). On Polymarket theconditionIdcan also be passed straight intogetMarket,getOrderbook, andgetTrades; on Kalshi every id is just the ticker; on predict.fun key those calls by the numericMarket.id(a rawpolymarketConditionIdsfield even links its mirrored markets to Polymarket's for cross-venue joins).outcomeId(token id /YES·NO) is consistent everywhere.
Scope
Implemented today: the full market-data surface — getMarkets,
getMarket, getTrendingMarkets, getEvents, getTrendingEvents,
getOrderbook, getTrades, getResolution, and
resolutionPolicy — plus account/positions reads and the full order
lifecycle on Polymarket, Kalshi, and predict.fun: placeOrder/placeOrders,
getOpenOrders/getOrder/getOrderHistory/getFills,
cancelOrder/cancelAllOrders, and estimateFees. These are the
methods on the client contract. (The authenticated
order-read/cancel wire shapes — Kalshi /portfolio/fills and the Polymarket
CLOB /data/orders·/data/order/{id}·/data/trades·DELETE /order — ship
LIVE=1-gated until verified against a real response.)
Not implemented in the market-data surface (and why):
searchMarkets— Kalshi exposes no free-text market-search endpoint, so the method cannot be made homogeneous across venues without a lossy client-side fallback. Deliberately left unimplemented rather than ship an uneven contract; usegetMarkets/getEventswithcategoryto narrow.getPriceHistory— deferred. Kalshi candlesticks expose OHLC+volume while Polymarket price-history is single{time, price}points; a homogeneousPricePointshape is still being settled.
Planned (🔜 in the matrix), roughly in order:
modifyOrder(orderId, changes)— amend price/size. Polymarket has no native amend, so it is cancel+replace and the returned order id may change; the uniform semantics with that caveat are pinned in the doc comment.getPriceHistory— deferred (see above;PricePointshape unsettled).- Streaming —
subscribeOrderbook/subscribeTrades/subscribeTicker(market) andsubscribeOrders/subscribeFills(your account). - Cross-venue —
getBestPriceon a separateMultiVenueClientfacade.
Not on the roadmap (yet): more venues (Manifold, PredictIt) and order cancellation/streaming in CI by default.
Polymarket placeOrder does not yet set on-chain USDC/CTF allowances — a real
order only fills if the operator has set them (planned to be auto-handled
internally by placeOrder, not exposed as a method). The Polymarket order wire
details (EIP-712 domain/struct, L2 HMAC preimage, poll shape) are gated behind
LIVE=1 until confirmed. The same applies to predict.fun: a fillable order
needs a USDT allowance on the market's exchange contract, and the order-submit
wire details (expiration 0, pricePerShare units, JWT expiry shape, maker
fees) stay LIVE=1-gated until the key-gated live blocks confirm them.
Development
pnpm install
pnpm build # tsup → dist/ (ESM + CJS + .d.ts)
pnpm test # vitest unit tests
pnpm typecheck # tsc --noEmit
pnpm lint # eslint .
pnpm example # build + run examples/quickstart.ts against live APIs
# Live smoke tests (opt-in). See .env.example for credentials.
LIVE=1 pnpm test:integrationSee [examples/](exa
