Polymarket’s CLOB takes 30-90 seconds to reprice after a large Binance spot move, and that lag is what this bot is built to trade. To build one in Python, you stream Binance prices over an asyncio WebSocket, detect a large move, and place maker orders on Polymarket’s CLOB, which pay no taker fee, before the odds catch up. I ran it with real money on 23 BTC trades in February 2026. That was enough to test the plumbing and far too few to say anything about profit.

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. The deployment infrastructure this bot runs on is documented in Deploy Python Agents to DigitalOcean (the infrastructure cluster’s pillar).

Here is the full architecture: what works, what broke, and what a small test run can and cannot tell you.

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
  • Where to run it: the Polymarket order book (CLOB) is hosted in London (eu-west-2). From Amsterdam the latency was 5-12ms, from US East 130-150ms. Server location and hosting
  • The test run: 23 real-money BTC trades from 02/13/2026 to 02/16/2026, too small a sample to show whether the signal makes money
  • 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 to be right about 60% of the time at a p=0.60 entry price just to break even, before any slippage or missed fills.

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
from py_clob_client.order_builder.constants import BUY

client = ClobClient(
    host="https://clob.polymarket.com",
    key=PRIVATE_KEY,
    chain_id=137,
    signature_type=1,      # POLY_PROXY for MagicLink/email accounts
    funder=FUNDER_ADDRESS,  # the wallet that holds your USDC (your polymarket.com address)
)
client.set_api_creds(client.create_or_derive_api_creds())  # needed before any post

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
    )
    signed = client.create_order(order_args)  # signs locally, sends nothing
    # post_only=True: rejected instead of filled if the price would cross the spread
    return client.post_order(signed, OrderType.GTC, post_only=True)

Checked against py-clob-client 0.34.6. Without funder, the SDK uses the signing key’s own address as the wallet that pays, and on an email account that wallet is empty. Without set_api_creds, post_order refuses to run.

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

create_order does not submit anything. It only signs the order locally. This is not obvious from the name. create_and_post_order signs and submits in one call, but always as a plain order with post_only off, so if your price crosses the spread it fills as a taker. For an order that is guaranteed to rest on the book as a maker, sign with create_order and submit with post_order(..., post_only=True), as above.

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:

from py_clob_client.clob_types import AssetType, BalanceAllowanceParams

usdc = BalanceAllowanceParams(asset_type=AssetType.COLLATERAL)

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

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

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, and always pass it a BalanceAllowanceParams: called with no argument, it crashes in py-clob-client 0.34.6. 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 before migrating to a specialized VPS like QuantVPS when my strategy demanded sub-10ms latency. If you want the full Droplet setup guide, it is at deploy a Python agent on DigitalOcean, and the 24/7 keep-alive setup (systemd, heartbeats, what a $6 box can actually carry) is in run your AI agent on a VPS.

Run your Polymarket bot on QuantVPS Amsterdam VPS: 5-12ms to Polymarket

Affiliate link. I may earn a commission at no extra cost to you.

What a 23-Trade Test Run Can and Cannot Tell You

I ran the bot with real money on 23 BTC trades from 02/13/2026 to 02/16/2026, at $5 to $15 per trade. I am not publishing a win rate or a profit figure from that run. Twenty-three trades is far too small a sample to separate a real edge from luck, and a number like that reads as a promise that no sample this size can keep.

What the run did show is that the plumbing works end to end with real money on the line, and it surfaced the silent bugs described below. Before you trust any signal, run it long enough that the result would survive a bad week, and size it so that a bad week does not matter. For the expected value and Kelly sizing math, see The Math Behind Directional Betting in Binary Markets. In the full system, an adaptive self-tuner scales the base bet on rolling performance.

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 decide whether a correct signal ends as a gain or a loss.

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. Reading the share balance right after a fill returned stale data: the CLOB could report an order as matched several seconds before the shares appeared. The fix was to wait 5 seconds after the fill, then read the share balance (get_balance_allowance with AssetType.CONDITIONAL and the token id) up to six times, 3 seconds apart, and refuse to record the position if the shares never arrived. A few lines of waiting. 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.

How this codebase grew from a 2-hour prototype into a 36,000-line production system, and the five Claude Code principles that kept it correct, is documented in the Claude trading bot build log.

See also: Claude Trading Bot in Production: 36,000 Lines, 1-in-40 Error Rate and, on the documentation habit that made this post citable in the first place, What Actually Predicts Whether AI Cites Your Content

Chudi Nnorukam

Written by Chudi Nnorukam

I build web systems that AI models can read and cite. The skill underneath is attention: I find what an AI assistant sees, or misses, about a site, then close the gap so the right pages get recommended. 5+ products shipped solo, concept to production in days. chudi.dev is the public, measured proof.