Skip to main content
Polymarket Bot: Build a Python Trader in 2 Hours

Polymarket Bot: Build a Python Trader in 2 Hours

Polymarket Bot: Build a Python Trader in 2 Hours
Chudi Nnorukam Sep 15, 2025 Updated Mar 11, 2026 9 min read

Build a Polymarket arbitrage bot: capture price gaps between Binance and CLOB in milliseconds. Full code walkthrough from WebSocket to execution.

Why this matters

I built a latency arbitrage bot for Polymarket by tapping Binance WebSocket price feeds and placing orders on the CLOB before market makers reprice. The core insight: Polymarket's 5-minute BTC up/down markets often lag Binance by 30-90 seconds. That lag is the edge. 69.6% win rate across 23 clean trades. Here's the full architecture.

In this cluster

Cluster context

This article sits inside Quantitative Trading Systems.

Open topic hub

Building, testing, and operating automated trading bots on prediction markets and crypto exchanges.

Trading systems are only interesting if they survive production. This cluster is about execution, robustness, and market reality.

I spent three months building a trading bot for Polymarket. Not a “signal subscriber” or a wrapper around someone else’s strategy — a system that finds mispricing, places orders, and manages exits autonomously with real money on the line.

Here is the full architecture: what works, what broke, and what the numbers look like.

TL;DR

  • The edge: Polymarket’s 5-minute BTC up/down markets lag Binance price moves by 30-90 seconds
  • The strategy: Detect a large BTC move on Binance, buy the corresponding YES/NO market on Polymarket before odds reprice
  • The stack: Python, asyncio, Binance WebSocket, py_clob_client, VPS in Amsterdam
  • The results: 69.6% win rate, +$11.51 net over 23 clean trades (Feb 13-16, 2025)
  • The hardest part: The bugs were silent — wrong API calls that succeeded but destroyed your position

Why Prediction Markets Have Exploitable Lags

Polymarket runs binary options on events. The 5-minute BTC up/down markets ask: will BTC be higher or lower in 5 minutes than it is right now?

Market makers on Polymarket reprice these markets in response to Binance spot price moves. But they are not colocated at Binance. There is latency in their observation, decision, and order update cycle.

That window — typically 30 to 90 seconds on large moves — is the edge. When BTC moves 0.3% in 60 seconds, the Polymarket YES probability for “BTC up” often hasn’t fully moved from 0.55 to 0.80 yet. You buy YES at 0.60, the market closes at 0.80 (already resolved YES by then), and you capture the spread.

The math on a winning trade with maker entry:

Entry: 100 shares at $0.60 = $60
Payout: 100 shares × $1.00 = $100
Profit: $100 - $60 = $40 (66.7% return)
Maker fee: $0 (Polymarket pays makers)

The math on a losing trade:

Entry: 100 shares at $0.60 = $60
Payout: $0 (market resolves other way)
Loss: -$60

This means the strategy needs roughly 60% win rate at p=0.60 entry price to break even — and historically the signal fires at higher-conviction entry points.

The Architecture

The system has four layers:

  1. Signal layer — Binance WebSocket detects >0.3% BTC move in 60 seconds
  2. Decision layer — checks market conditions, filters, existing positions
  3. Execution layer — places maker order on Polymarket CLOB
  4. Exit layer — holds to resolution or uses a stop-loss on adverse move

Signal Layer: Binance WebSocket

For a complete breakdown of this stage — including the full MomentumDetector, SignalGuard, and reconnect architecture — see Binance to Polymarket: Building a Real-Time Momentum Signal Pipeline.

import asyncio
import websockets
import json
from collections import deque

THRESHOLD_PCT = 0.003   # 0.3%
WINDOW_SECS = 60

price_window: deque = deque()

async def monitor_binance():
    url = "wss://stream.binance.com:9443/ws/btcusdt@aggTrade"
    async with websockets.connect(url) as ws:
        async for msg in ws:
            data = json.loads(msg)
            price = float(data["p"])
            ts = data["T"] / 1000

            price_window.append((ts, price))
            # Prune window
            cutoff = ts - WINDOW_SECS
            while price_window and price_window[0][0] < cutoff:
                price_window.popleft()

            if len(price_window) < 2:
                continue

            oldest_price = price_window[0][1]
            pct_move = (price - oldest_price) / oldest_price

            if abs(pct_move) >= THRESHOLD_PCT:
                direction = "UP" if pct_move > 0 else "DOWN"
                await on_signal(direction, pct_move, price)

The key calibration: 30-second/0.15% threshold produced zero signals in testing. 60-second/0.3% produced 62% of BTC moves landing in-range. You need enough signal frequency to generate trades, but not so much that you’re trading noise.

Execution Layer: py_clob_client

The Polymarket Python SDK has some surprises. Here is the correct order placement pattern:

from py_clob_client.client import ClobClient
from py_clob_client.clob_types import OrderArgs, OrderType

client = ClobClient(
    host="https://clob.polymarket.com",
    key=PRIVATE_KEY,
    chain_id=137,
    signature_type=1,  # POLY_PROXY for MagicLink/email accounts
)

async def place_maker_order(token_id: str, price: float, size: float):
    order_args = OrderArgs(
        token_id=token_id,
        price=price,
        size=size,
        side=BUY,  # always BUY — YES and NO are separate tokens
    )
    # This creates AND submits the order in one call
    result = client.create_and_post_order(order_args)
    return result

Two things that will burn you if you don’t know them:

Always use create_and_post_order, not create_order. The SDK has both. create_order only creates a local order object — it does not submit it. This is not obvious from the name.

Always use side=BUY. YES and NO are separate tokens on Polymarket. You never “sell NO” — you “buy YES.” When you want to exit a YES position, you place a SELL order, but entry is always BUY on the token you want to hold.

Signature Types

This tripped me up for a week. Polymarket has three signature types:

  • Type 0 (EOA): Standard private key, EIP-712 signing. Works for wallets you control directly.
  • Type 1 (POLY_PROXY): For MagicLink/email accounts. Gasless proxy contract signing. Different from type 0 in ways that matter for allowances.
  • Type 2 (POLY_GNOSIS_SAFE): MetaMask browser wallet, Gnosis Safe. Not suitable for automated bots.

If you have a MagicLink account (most consumer Polymarket accounts), you need type 1. Using type 0 on a type 1 account will produce valid-looking signatures that the CLOB silently rejects.

The Allowance Bug That Cost Real Money

The CLOB tracks internal allowances separately from on-chain state. There are two API calls that sound similar but do opposite things:

# SAFE: reads current CLOB internal allowance
client.get_balance_allowance()

# DANGEROUS: reads on-chain, then OVERWRITES CLOB internal allowance
client.update_balance_allowance()

I had code that called update_balance_allowance() before placing sell orders to “confirm I had enough allowance.” This overwrote the CLOB’s internal allowance with a freshly read on-chain value — which, after a recent fill, was zero. Every sell after that failed silently because the CLOB thought I had no allowance.

The fix: never call update_balance_allowance() after a fill. Only call get_balance_allowance() to read. The CLOB updates its internal state correctly on its own after fills.

Server Location Is Not Optional

The Polymarket CLOB is hosted in London (eu-west-2). Latency from different locations:

LocationLatency
Amsterdam5-12ms
Frankfurt15-25ms
US East (NYC)130-150ms
US West180-200ms

At 130ms from US East, you are behind every European competitor by 120ms. In a strategy where the edge window is 30-90 seconds, 120ms is not catastrophic — but it compounds with every other source of latency.

My bot runs on a QuantVPS instance in Amsterdam. The difference in fill quality between the Amsterdam server and testing from my Mac in San Francisco was measurable. I started on a $6/month DigitalOcean Droplet ($200 free credit for new signups) before migrating to a specialized VPS when my strategy demanded sub-10ms latency. If you want the full Droplet setup guide, it’s at deploy a Python agent on DigitalOcean.

What the Numbers Look Like

Over 23 clean BTC trades (February 13-16, 2025, excluding deployment-disrupted trades):

  • Win rate: 69.6% (16/23)
  • Net P&L: +$11.51
  • Average per trade: +$0.50
  • BTC Down signal: 75% win rate (strongest direction)
  • BTC Up signal: 65% win rate

“Clean” means I excluded trades during bot restarts, code deploys, and the 48-hour period after I changed the entry threshold. Deployment-disrupted trades are not strategy losses — they’re operational noise.

The win rate of 69.6% at average entry prices around p=0.62-0.68 puts this well above break-even. The position sizing is conservative ($5-15 per trade) because this is early validation, not full deployment — in the full system, an adaptive self-tuner scales that base bet based on rolling performance. For the expected value and Kelly sizing math behind why that win rate translates to positive P&L, see The Math Behind Directional Betting in Binary Markets.

The Exit Problem

Most of the complexity is not in signal generation or order placement — it is in exit management.

Options for exit:

  1. Hold to resolution: simplest, highest EV if your directional signal is right
  2. Stop-loss on adverse move: cut the position if odds move against you beyond a threshold
  3. Time-based exit: exit N minutes before market close

“Hold to resolution” sounds right but requires discipline. A position at 0.65 that moves to 0.40 before resolving YES will feel terrible even if the final outcome is correct. Most people override their bot at exactly the wrong moment.

I use hold-to-resolution as the default with a secondary check: if position moves against me AND the market has more than 3 minutes left AND I am down more than 30% on the position, I exit and redeploy elsewhere. This is not optimal from a pure EV perspective, but it prevents large single-trade losses from ending the session.

What I Would Do Differently

1. Build the exit manager before the entry logic. Entries are easy to tune. Exits determine whether a 69% win rate turns into positive P&L or not.

2. Validate the expiry check from day one. Markets close abruptly. If you place an order 1 second after a market closes, the fill happens but you can not exit. Add secs_remaining <= 0 checks before every order.

3. Log everything to a DB immediately. I lost the first two weeks of data because I was logging to a text file that got rotated. SQLite with trades, fills, and exits gives you something to analyze. Every trade should record: entry price, exit price, signal strength, market ID, timestamps, and the reason the exit triggered. Without this data, you cannot distinguish between strategy problems and execution problems. Most early debugging sessions ended with “I think it lost money on that trade but I can not prove why.” That is an unacceptable state for a system handling real capital.

4. Colocation is table stakes, not optimization. I treated server location as something to optimize later. It is actually part of the core strategy definition.

Operational Challenges: When Theory Meets Production

Running the bot on a live VPS introduced challenges that don’t show up in local testing. The first week was dominated by what I call “invisible failures” — operations that succeeded at the API level but broke the underlying strategy logic in ways that took days to diagnose.

The most insidious: after a successful fill, the bot would enter a state where it couldn’t exit. It had capital locked in a position but no way to liquidate. The root cause was subtle — the CLOB’s internal state tracking after a fill didn’t always sync with what the REST API reported for balance. Polling get_balance() after a fill would return stale data. The solution was adding a 500ms delay before checking balance post-fill and validating against multiple sources. A single delay. Days of debugging.

Network reliability mattered more than code quality. The Binance WebSocket connection dropped 2-3 times per 24-hour session, sometimes silently (no error event, just dead). The reconnect loop caught most of it, but during the blind period (first 60 seconds after reconnect), the bot would miss signals or, worse, place orders on old signal data. Solution: on reconnect, dump the entire rolling window and reseed from REST API klines for the last 120 seconds before resuming signal processing.

Position management in production revealed another gap: the bot would place an order, get filled, then lose the fill notification if the connection hiccupped between order acknowledge and fill event. The CLOB would have the position, but the bot wouldn’t know about it. This left zombie positions open. The fix was polling the CLOB’s open positions on every signal — expensive, but necessary for correctness.

Chudi Nnorukam

Written by Chudi Nnorukam

I develop products using AI-assisted workflows — from concept to production in days. chudi.dev is a live public experiment in AI-visible web architecture, designed for human readers, LLM retrieval, and AI agent interoperability. 5+ deployed products including production trading systems, SaaS tools, and automation platforms.

FAQ

Is Polymarket trading profitable?

It can be with a genuine edge. Latency arbitrage — exploiting the lag between Binance price moves and Polymarket odds repricing — produced a 69.6% win rate across 23 clean trades. Without an edge, you'll lose to fees and informed market makers.

What programming language is best for Polymarket bots?

Python with py_clob_client is the natural choice since Polymarket provides an official Python SDK. The async event loop handles WebSocket feeds and order placement cleanly.

How do Polymarket signature types work?

Type 0 is EOA (standard private key signing). Type 1 is POLY_PROXY, used by MagicLink/email accounts — it's gasless and works differently from standard EIP-712. Type 2 is GNOSIS_SAFE for MetaMask browser wallets.

What server location should I use for a Polymarket bot?

Amsterdam (eu-west-2 region) gives 5-12ms to Polymarket's CLOB in London. US East gives 130-150ms. That 120ms disadvantage is significant in a latency-sensitive strategy.

What is the minimum order size on Polymarket?

5 USDC shares minimum. Position sizing below this threshold will be rejected by the CLOB.

Sources & Further Reading

Sources

Further Reading

Quantitative Trading Systems updates

Continue the Quantitative Trading Systems track

This signup keeps the reader in the same context as the article they just finished. It is intended as a track-specific continuation, not a generic site-wide interrupt.

  • Next posts in this reading path
  • New supporting notes tied to the same cluster
  • Distribution-ready summaries instead of generic blog digests

Segment: quantitative-trading

What do you think?

I post about this stuff on LinkedIn every day and the conversations there are great. If this post sparked a thought, I'd love to hear it.

Discuss on LinkedIn