DEV Community

Chudi Nnorukam
Chudi Nnorukam

Posted on • Originally published at chudi.dev

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

Originally published at chudi.dev


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.

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

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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 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
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)