Skip to main content
Binance to Polymarket: Building a Real-Time Momentum Signal Pipeline

Binance to Polymarket: Building a Real-Time Momentum Signal Pipeline

How to wire Binance WebSocket price feeds into a Polymarket trading bot — signal detection, filtering, deduplication, and latency-optimized order flow.

Chudi Nnorukam
Chudi Nnorukam
Oct 15, 2025 Updated Feb 20, 2026 6 min read
In this cluster

Quantitative Trading Systems: Building, testing, and operating automated trading bots on prediction markets and crypto exchanges.

Pillar guide

How I Built a Polymarket Trading Bot That Actually Makes Money A full technical walkthrough of building a latency arbitrage bot for Polymarket prediction markets — from Binance WebSocket signals to CLOB order placement.

Related in this cluster

Prediction markets are slow to reprice. Binance spot is fast. The pipeline between them is where the edge lives.

This post covers the full signal pipeline for a Polymarket latency arbitrage bot: from Binance WebSocket stream to CLOB order placement, including the filtering, deduplication, and async architecture that prevents duplicate entries and missed signals. If you want the full system architecture first, start with how I built the Polymarket trading bot — this post is a deep-dive on Stages 1 through 3 of that architecture.

TL;DR

  • Stream Binance aggTrade via WebSocket — tick-level data, not candles
  • Rolling 60-second window detects >0.3% momentum
  • Signal guard suppresses re-entry on the same direction
  • Async executor places CLOB maker order within 100-200ms of signal detection
  • Run on Amsterdam VPS: 5-12ms to Polymarket CLOB in London

Why This Pipeline Exists

Polymarket lists binary markets on 5-minute BTC price movements: “Will BTC be higher in 5 minutes?” Market makers reprice YES/NO probabilities based on Binance spot. When BTC moves sharply, there is a 30-90 second lag before Polymarket odds fully reflect the move.

The pipeline exploits that lag by:

  1. Detecting the move on Binance first
  2. Placing a maker order on Polymarket before market makers reprice
  3. Collecting the spread between entry price and resolved probability — the expected value math that makes this profitable is straightforward once you have a calibrated p_true

Stage 1: Binance WebSocket Stream

The signal source is Binance’s aggTrade stream — not klines (candlesticks). Here is why that distinction matters:

  • aggTrade: fires on every individual trade, sub-second latency
  • klines/1m: fires once per minute, 0-60 second stale data window

For a strategy where the edge window is 30-90 seconds, klines make the signal source 33-100% as wide as the edge itself. You need tick data.

import asyncio
import websockets
import json
from collections import deque
from dataclasses import dataclass
from typing import Optional

@dataclass
class Tick:
    timestamp: float  # unix seconds
    price: float

class BinanceStream:
    SYMBOL = "btcusdt"
    URL = f"wss://stream.binance.com:9443/ws/{SYMBOL}@aggTrade"

    def __init__(self, on_tick):
        self._on_tick = on_tick
        self._running = False

    async def run(self):
        self._running = True
        backoff = 1
        while self._running:
            try:
                async with websockets.connect(self.URL) as ws:
                    backoff = 1  # reset on successful connect
                    async for raw in ws:
                        msg = json.loads(raw)
                        tick = Tick(
                            timestamp=msg["T"] / 1000,
                            price=float(msg["p"])
                        )
                        await self._on_tick(tick)
            except Exception as e:
                await asyncio.sleep(backoff)
                backoff = min(backoff * 2, 30)  # exponential backoff, max 30s

    def stop(self):
        self._running = False

The reconnect loop with exponential backoff is not optional. Binance WebSocket connections drop. Without reconnect logic, your bot goes silent and you don’t notice until you check P&L and find it hasn’t traded in 6 hours.

Stage 2: Rolling Window Momentum Detector

The detector maintains a 60-second rolling window of price ticks and fires a signal when the net move exceeds the threshold.

from collections import deque
from enum import Enum
from typing import Optional

class Direction(Enum):
    UP = "UP"
    DOWN = "DOWN"

class MomentumDetector:
    THRESHOLD_PCT = 0.003   # 0.3%
    WINDOW_SECS = 60

    def __init__(self):
        self._window: deque[Tick] = deque()

    def update(self, tick: Tick) -> Optional[Direction]:
        self._window.append(tick)
        self._prune(tick.timestamp)

        if len(self._window) < 2:
            return None

        oldest = self._window[0].price
        newest = self._window[-1].price
        pct_move = (newest - oldest) / oldest

        if pct_move >= self.THRESHOLD_PCT:
            return Direction.UP
        if pct_move <= -self.THRESHOLD_PCT:
            return Direction.DOWN
        return None

    def _prune(self, now: float):
        cutoff = now - self.WINDOW_SECS
        while self._window and self._window[0].timestamp < cutoff:
            self._window.popleft()

The deque pruning keeps memory bounded regardless of how long the bot runs. Without pruning, the window grows unboundedly and the oldest price comparison becomes meaningless.

Threshold Calibration

0.3% in 60 seconds is the calibrated threshold for BTC. How I arrived at it:

  • 0.15% in 30s: fires constantly — 15-20 signals per day. Most resolve as noise.
  • 0.3% in 60s: fires 3-8 times per day. 62% of signals land in-range (market resolves in signal direction).
  • 0.5% in 60s: fires 0-2 times per day. Too infrequent to validate or build statistical confidence.

The 62% in-range rate translates directly to your p_true estimate for position sizing — see the math behind directional betting in binary markets for how to convert that into expected value and Kelly fraction.

The threshold needs recalibration for other assets. ETH has a different noise floor than BTC. SOL and XRP are noisier at shorter windows.

Stage 3: Signal Guard

Without a signal guard, a sustained 3-minute BTC rally fires 3 separate signals — and the bot opens 3 long positions on what is effectively one trade. The signal guard prevents this.

import time

class SignalGuard:
    COOLDOWN_SECS = 120  # 2 minutes

    def __init__(self):
        self._last_direction: Optional[Direction] = None
        self._last_signal_ts: float = 0

    def should_trade(self, direction: Direction) -> bool:
        now = time.time()
        time_since_last = now - self._last_signal_ts

        # Same direction within cooldown: suppress
        if (direction == self._last_direction and
                time_since_last < self.COOLDOWN_SECS):
            return False

        self._last_direction = direction
        self._last_signal_ts = now
        return True

The guard resets on direction change. A BTC DOWN signal immediately after a BTC UP position is a new, independent signal — not a duplicate. Only same-direction signals within the cooldown window are suppressed.

Stage 4: CLOB Order Executor

The executor receives a validated signal and places a maker order on the Polymarket CLOB. The BASE_BET_USDC here is fixed for simplicity — in production, a self-tuning system adjusts this value dynamically based on recent win rate.

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

class CLOBExecutor:
    MIN_SHARES = 5       # Polymarket minimum
    BASE_BET_USDC = 15   # dollar amount per trade

    def __init__(self, client: ClobClient, market_registry):
        self._client = client
        self._registry = market_registry

    async def execute(self, direction: Direction) -> Optional[str]:
        market = self._registry.get_active_5m_btc_market(direction)
        if not market:
            return None

        # Check market has enough time remaining
        if market.secs_remaining <= 30:
            return None

        # Compute shares from dollar amount
        mid_price = market.mid_price
        shares = self.BASE_BET_USDC / mid_price

        if shares < self.MIN_SHARES:
            return None

        order_args = OrderArgs(
            token_id=market.token_id,
            price=mid_price,
            size=round(shares, 2),
            side=BUY,
        )

        result = self._client.create_and_post_order(order_args)
        return result.order_id if result else None

The expiry check (secs_remaining <= 30) prevents placing orders on markets that are about to close. Without it, you fill on a market that resolves 10 seconds later and cannot place an exit if needed.

Wiring It Together: Async Event Loop

The full pipeline runs on a single asyncio event loop. The WebSocket receive loop, momentum detection, and CLOB placement all happen asynchronously without blocking each other.

async def main():
    detector = MomentumDetector()
    guard = SignalGuard()
    executor = CLOBExecutor(client, registry)

    async def on_tick(tick: Tick):
        direction = detector.update(tick)
        if direction is None:
            return

        if not guard.should_trade(direction):
            return

        order_id = await executor.execute(direction)
        if order_id:
            log.info(f"Placed order {order_id}{direction.value}")

    stream = BinanceStream(on_tick)
    await stream.run()

asyncio.run(main())

The event loop is single-threaded but non-blocking. on_tick is called for every Binance trade tick. The CLOB placement is awaited inline — if the CLOB call takes 50ms, the next tick is processed after it completes.

For strategies with heavier logic, you can offload placement to a separate task:

async def on_tick(tick: Tick):
    direction = detector.update(tick)
    if direction and guard.should_trade(direction):
        asyncio.create_task(executor.execute(direction))

This lets tick processing continue while placement runs in the background.

Latency Breakdown

On a well-tuned Amsterdam VPS, the pipeline latency from Binance tick to CLOB order acknowledgement:

StageLatency
Binance WS → Python receive1-5ms
Deque update + threshold check<1ms
Signal guard check<1ms
CLOB order construction<1ms
CLOB API round trip (Amsterdam→London)5-12ms
Total~10-20ms

20ms from Binance tick to CLOB order. The Polymarket repricing lag is 30-90 seconds. You have 30-90,000ms of edge window, and you use 20ms of it.

The math still holds even with US East latency (130-150ms total). The bottleneck is not network — it is whether anyone else detected the signal first.

What Can Go Wrong

WebSocket disconnects during a live position. Your position is still open, but you have no incoming price data to decide when to exit. Solution: implement position health checks on reconnect that query CLOB for open positions before resuming signal processing.

Market not found for a signal direction. The 5-minute BTC market rolls over every 5 minutes. If a signal fires at minute 4:58, the current market might have 2 seconds remaining. Your registry needs to handle market lookup gracefully with a fallback to the next available market.

CLOB rate limits. If you place too many orders in rapid succession, the CLOB returns rate limit errors. The signal guard helps here, but also implement a per-minute order count limiter with an asyncio.sleep backoff.

Silent order rejections. The CLOB occasionally rejects orders with a 200 OK but no order_id in the response. Always check the return value — do not assume success from HTTP status alone.

Signal Frequency Expectations

On active BTC trading days:

  • 3-8 signals with 0.3%/60s threshold
  • Each signal potentially captures a 5-minute market (or what remains of it)
  • 1-3 of those signals will be suppressed by the guard as duplicates of a sustained move

On quiet days (low volatility): 0-2 signals. This is fine. The strategy is about edge quality, not trade frequency.

Chudi Nnorukam

Written by Chudi Nnorukam

I design and deploy agent-based AI automation systems that eliminate manual workflows, scale content, and power recursive learning. Specializing in micro-SaaS tools, content automation, and high-performance web applications.

FAQ

Why use Binance as the signal source instead of Polymarket itself?

Polymarket's 5-minute BTC markets reprice in response to Binance — not the other way around. Binance spot is the primary market. Monitoring it directly gives you the earliest possible signal before Polymarket market makers update their quotes.

What is the aggTrade stream and why is it better than klines?

aggTrade streams every individual trade as it happens. Klines aggregate trades into OHLCV candles, introducing up to 60 seconds of delay depending on candle interval. For latency-sensitive strategies, aggTrade is the only option.

How do you prevent the bot from trading the same signal twice?

A signal guard tracks the last signal direction and timestamp. If a new signal fires in the same direction within a cooldown window (typically 2 minutes), it is suppressed. This prevents stacking multiple positions on a single sustained move.

What happens when the Binance WebSocket disconnects?

Implement reconnect logic with exponential backoff. On reconnect, reseed the rolling window from REST API klines for the last 60 seconds before resuming. Without reseeding, the first 60 seconds after reconnect are blind.

Should I run the Binance listener and CLOB executor in separate processes?

For simple strategies, a single asyncio event loop handles both cleanly. For strategies with heavy order management logic, separate processes with a queue improve isolation and prevent order handling from blocking signal detection.

Sources & Further Reading

Sources

Further Reading

Discussion

Comments powered by GitHub Discussions coming soon.