
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.
In this cluster
Quantitative Trading Systems: Building, testing, and operating automated trading bots on prediction markets and crypto exchanges.
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:
- Detecting the move on Binance first
- Placing a maker order on Polymarket before market makers reprice
- 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:
| Stage | Latency |
|---|---|
| Binance WS → Python receive | 1-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.
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
- Binance WebSocket API — Aggregate Trade Streams Official documentation for the aggTrade WebSocket stream used as the signal source.
- Python asyncio — Event Loop Reference for the async event loop pattern used to multiplex WebSocket and CLOB operations.
- Polymarket py_clob_client Python SDK for CLOB order placement on Polymarket.
Further Reading
- 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.
- Self-Tuner: Building an Adaptive Position Sizing System in Python How to build a self-tuning position sizing system that adjusts bet size based on recent performance — without overfitting or over-reacting to variance.
- The Math Behind Directional Betting in Binary Markets Expected value, Kelly criterion, and position sizing for binary prediction markets. The mathematics that separate profitable directional bets from guessing.
Discussion
Comments powered by GitHub Discussions coming soon.