// 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:
[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:
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.
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):
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:
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:
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.