API + MCP live · talk to us

// PREDICTION-MARKET APIS

How to build a prediction-market trading bot in Python

2026-07-16 · Mithril

Most prediction-market bots die the same way: not from a bad signal, but from bad plumbing — a duplicate order after a timeout, a stale book, a position that drifted from what the bot believed it held. This post is about the plumbing. It lays out a venue-agnostic architecture in Python that works whether you trade Kalshi, Polymarket, or both, and spends its time on the parts that actually break: the data loop, the signal-to-order pipeline, risk checks, and idempotent retries.

Venue-specific mechanics (auth, endpoints, signing) are covered in the Kalshi API guide and the Polymarket CLOB API guide; here they hide behind an adapter interface.

The architecture

Four components, one direction of data flow:

text
[venue adapters] → [market data loop] → [strategy] → [execution engine]
        ↑                                                  |
        └────────────── reconciliation ←──────────────────┘
  • Venue adapters speak each API's dialect and translate to one internal model.
  • The market data loop maintains your current view of books and prices.
  • The strategy turns that view into desired positions.
  • The execution engine turns desired positions into orders — with risk checks and idempotency — and reconciliation closes the loop.

Keep these as separate modules from day one. Every "quick script" that mixes them becomes unmaintainable the first time you add a second market.

The internal model

Normalize at the boundary. One representation for prices (decimal probability), one for markets, one for orders:

python
from dataclasses import dataclass
from enum import Enum

class Side(Enum):
    BUY_YES = "buy_yes"
    BUY_NO = "buy_no"

@dataclass(frozen=True)
class Quote:
    market_id: str      # your internal ID, not the venue's
    bid: float          # decimal probability, 0.01–0.99
    ask: float
    bid_size: int
    ask_size: int
    ts: float

@dataclass(frozen=True)
class OrderIntent:
    market_id: str
    side: Side
    limit_price: float  # decimal
    size: int
    reason: str         # audit trail: why the strategy wants this

Kalshi's integer cents and Polymarket's token-level decimals both convert into this at the adapter, and nothing above the adapter ever sees a raw venue price. If you trade both venues, you also need a mapping from your internal market_id to each venue's identifier — a harder problem than it sounds (unified market IDs explains why).

The market data loop

Two sources of truth, used together:

  • WebSockets for freshness: subscribe to book updates and your own fills.
  • REST snapshots for correctness: on connect, on reconnect, and on a slow timer, fetch the full book and replace your local copy.
python
import asyncio, time

class MarketData:
    def __init__(self, adapter, market_ids, max_staleness=10.0):
        self.adapter = adapter
        self.market_ids = market_ids
        self.max_staleness = max_staleness
        self.quotes: dict[str, Quote] = {}

    async def run(self):
        async for quote in self.adapter.stream_quotes(self.market_ids):
            self.quotes[quote.market_id] = quote

    def get(self, market_id: str) -> Quote | None:
        q = self.quotes.get(market_id)
        if q is None or time.time() - q.ts > self.max_staleness:
            return None  # stale → the strategy must not trade on it
        return q

The load-bearing detail is the staleness check. A bot that trades on a book from a silently-dead WebSocket is a bot that donates money. Never return stale data as if it were fresh; return nothing and let the strategy sit out.

Signal → order

Keep the strategy pure: quotes and positions in, intents out, no I/O. A deliberately dumb example (this is not trading advice — the point is the shape):

python
def fair_value_strategy(quote: Quote, fair: float,
                        edge: float = 0.03, size: int = 10):
    intents = []
    if quote.ask < fair - edge:
        intents.append(OrderIntent(quote.market_id, Side.BUY_YES,
                                   quote.ask, size,
                                   f"ask {quote.ask} < fair {fair} - edge"))
    if quote.bid > fair + edge:
        intents.append(OrderIntent(quote.market_id, Side.BUY_NO,
                                   1 - quote.bid, size,
                                   f"bid {quote.bid} > fair {fair} + edge"))
    return intents

Purity buys you two things: the strategy is unit-testable against recorded quotes, and every order carries a reason you can read in the logs at 2am. Note the binary-market symmetry — "sell yes at bid" is expressed as buying no at 1 − bid, so one code path handles both directions.

Risk checks: before the order, not after

Every intent passes through a gate before it becomes an API call:

python
class RiskGate:
    def __init__(self, max_order_notional=50.0, max_market_notional=200.0,
                 max_total_notional=1000.0, max_orders_per_min=10):
        ...

    def check(self, intent: OrderIntent, portfolio) -> str | None:
        notional = intent.limit_price * intent.size
        if notional > self.max_order_notional:
            return "order notional cap"
        if portfolio.notional(intent.market_id) + notional > self.max_market_notional:
            return "per-market cap"
        if portfolio.total_notional() + notional > self.max_total_notional:
            return "total exposure cap"
        if self.recent_order_count() >= self.max_orders_per_min:
            return "order rate cap"  # runaway-loop breaker
        return None  # pass

The order-rate cap is the one people skip and regret: it is the difference between a bug that costs one bad order and a bug that costs four hundred. Add a kill switch — a single flag that halts all order placement — and check it inside the gate, not in the strategy. In-process checks like these share a failure domain with the bug they guard against, which is the argument for enforcing limits server-side as well (risk limits for AI trading agents covers that layer).

Idempotent retries: the part that saves you

The nastiest failure in trading systems is the ambiguous timeout: you POST an order, the connection drops, and you do not know if it was accepted. The fix is to make placement idempotent:

python
import uuid

async def place_with_retry(adapter, intent: OrderIntent, retries=3):
    client_order_id = str(uuid.uuid4())  # ONE id for all attempts
    for attempt in range(retries):
        try:
            return await adapter.place(intent, client_order_id)
        except AmbiguousResult:
            existing = await adapter.find_order(client_order_id)
            if existing is not None:
                return existing          # first attempt landed
            await asyncio.sleep(0.5 * 2 ** attempt)
        except RetryableError:
            await asyncio.sleep(0.5 * 2 ** attempt)
    raise PlacementFailed(intent)

The rules: generate the idempotency key once per intent, not per attempt; on ambiguity, look before you resend; back off exponentially with jitter. Kalshi supports a client_order_id natively; on Polymarket, lean on checking your open orders before re-posting a re-signed order. The venue differences here are real — the differences that bite has the details — which is why retry logic belongs in the adapter, not shared code.

Reconciliation: trust, but verify

However good your event handling, your local state will drift: a missed WebSocket message, a fill during a reconnect, a manual trade from the website. Run a reconciliation loop every 30–60 seconds that fetches positions and open orders from the venue and treats the venue as the source of truth. If local and remote disagree, log loudly, adopt the venue's view, and — if the divergence is large — trip the kill switch. Bots that skip this step don't crash; they trade confidently on fiction, which is worse.

What's deliberately missing

Backtesting, signal research, and sizing (see Kelly sizing for binary contracts) are their own topics. And everything above assumes one venue at a time — trading both venues from one bot adds market mapping, fee-aware venue selection, and split execution. You can build that layer yourself on top of the adapters described here, or use Mithril, which provides it as one REST API with unified mkt_ IDs, fee-aware routing across Kalshi and Polymarket, server-side risk limits, and an execution receipt on every fill — so your bot keeps the strategy and sheds the plumbing.

APIs change. Details above reflect public documentation as of July 2026 — always confirm against the venues' own docs before trading real money.

One API + MCP for Kalshi and Polymarket

Fee-aware routing, unified market IDs, and hard server-side risk limits — live today, free during beta.