This guide walks you through every layer of a working Polymarket trading bot , from wallet setup and API auth to signal logic, order placement, and automated scheduling , using the BTC Up/Down 15M market as a live running example.
- How Polymarket's CLOB works and how it differs from a traditional exchange
- How to set up a Polygon wallet, fund it with USDC.e and wrap it to pUSD, and generate API credentials
- How to fetch live market data and build a simple directional signal for BTC
- How to place, monitor, and cancel limit orders on the CLOB
- How to add risk controls, automate the execution loop, and go live safely
- Python 3.10+ installed locally
- A MetaMask or compatible wallet with pUSD on Polygon (wrapped from USDC.e via Polymarket's CollateralOnramp)
- Basic Python familiarity (functions, loops, dicts)
- A Polymarket account with API access enabled
- Access to Claude Code, Cursor, or ChatGPT for AI-assisted coding
Understand What a Polymarket Trading Bot Is
Before writing a single line of code, you need a clear mental model of what a trading bot actually does on Polymarket and how Polymarket differs from a normal crypto exchange. This step covers the architecture of a bot, the unique mechanics of prediction markets, and the specific properties of the BTC Up/Down 15M market you will be trading. Getting this foundation right prevents the most common beginner mistakes later.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial. I'm on Step 01: Understand What a Polymarket Trading Bot Is. Step goal: Before writing a single line of code, you need a clear mental model of what a trading bot actually does on Polymarket and how Polymarket differs from a normal crypto exchange. This step covers the architecture of a bot, the unique mechanics of prediction markets, and the specific properties of the BTC Up/Down 15M market you will be trading. Getting this foundation right prevents the most common beginner mistakes later. Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on. --- Specific sub-tasks to complete during this step: ## TASK 1: Prompt: Give Your Coding Agent Polymarket Context Paste this into Claude Code, Cursor, or ChatGPT at the very start of your project session. It gives the agent the foundational context it needs to generate accurate Polymarket code throughout the tutorial. You are helping me build a Python trading bot for Polymarket in 2026. I've downloaded the starter project. It contains: - A `src/` skeleton with stubs you'll help me fill in - A `docs/polymarket/` mirror of docs.polymarket.com (161 pages of API docs as Markdown) - `docs/price-feeds/polymarket-rtds-reference.md` and `docs/price-feeds/coinbase-advanced-trade-ws.md` - `docs/gotchas.md` covering every common bug - A project-level `CLAUDE.md` that you should read first ALWAYS check the local `docs/` folder before answering Polymarket-specific questions. Do not guess API surfaces from training data. Key facts: - Polymarket is a binary prediction market on Polygon mainnet (chain ID 137, NOT Ethereum). Each market has two outcomes; one pays $1 and the other pays $0. - For BTC Up/Down crypto markets, the outcomes are labeled UP and DOWN (NOT YES/NO). Use that terminology in code and prose. - The BTC Up/Down 15M market opens and resolves every 15 minutes. The active market's `condition_id` and token IDs change each cycle. Never hardcode them. - Resolution uses the UMA Optimistic Oracle. For BTC Up/Down markets, the price the proposer reads is Chainlink's BTC/USD data stream, not Binance, not Coinbase. - Polymarket uses a Central Limit Order Book (CLOB) at https://clob.polymarket.com. The official Python SDK is `py-clob-client-v2` (V1 is deprecated). Install: pip install py-clob-client-v2. - Collateral is pUSD, an ERC-20 wrapper backed 1:1 by USDC.e on Polygon. Wrap via Polymarket's CollateralOnramp or deposit via the official bridge (which auto-wraps). - API keys are tied to the wallet proxy address; regenerate them if the wallet changes. - Order prices are decimal probabilities between 0.01 and 0.99 (NOT dollar amounts). Tick size is typically 0.01; round limit prices down to two decimals before submitting. - Order sizes are denominated in pUSD, not shares. - Rate limits are strict; use exponential back-off on 429. - Two WebSocket endpoints matter for this bot: `wss://ws-live-data.polymarket.com` (RTDS , Chainlink BTC/USD prices) and `wss://ws-subscriptions-clob.polymarket.com/ws/market` (CLOB market channel , per-market orderbook). The CLOB market WS must resubscribe at every cycle roll because token IDs change. - RTDS keep-alive is the literal text `PING` sent every 5 seconds. Coinbase WS uses standard WebSocket protocol ping frames. I'll build the bot step by step. Flag any Polymarket-specific gotchas as we go, and when you're unsure, cite the local doc you used.
What a Trading Bot Does
A trading bot is a program that replaces manual decision-making with automated logic. It runs on a loop: fetch data, generate a signal, place an order, manage the position, then repeat. On a centralized crypto exchange, that loop trades spot or futures prices directly. On Polymarket, the loop trades *probabilities* , specifically, the probability that some event resolves UP or DOWN.
Polymarket is a prediction market, not a price exchange. Every market is a binary question with a deadline. The BTC Up/Down 15M market asks: 'Will BTC be higher in 15 minutes than it is right now?' UP shares pay $1 if true, DOWN shares pay $1 if false. The price of a UP share at any moment reflects the crowd's implied probability , a UP price of 0.58 means the market thinks there is a 58% chance BTC goes up.
Your bot's job is to find moments when that implied probability is *wrong* , when the market is mispricing the outcome , and place a bet before the market corrects. That edge can come from faster data, a better model, or simply being disciplined when others are not. The bot automates the mechanical parts so you can focus on the edge.
The BTC Up/Down 15M Market , Your Running Example
The BTC Up/Down 15M market is one of Polymarket's highest-volume short-duration markets. A new market opens every 15 minutes, resolves at the close of that window, and is immediately replaced by the next one. This means your bot cannot hardcode a market ID , it must look up the *currently active* market ID on every cycle.
Prices on this market move fast. The UP price often swings between 0.40 and 0.65 in the minutes before resolution. That volatility is where the opportunity lives, but it also means stale data or slow order submission can flip a profitable trade into a losing one. Speed and freshness of data matter more here than in slower markets.
Throughout this guide, every code example and prompt is written for this specific market. The patterns transfer directly to any other binary market on Polymarket , you just swap the market slug and adjust the signal logic.
The BTC 15M market resolves against the Chainlink BTC/USD data stream. The Chainlink price at the resolution timestamp is what determines UP or DOWN, not Polymarket's order book and not any single exchange. Your bot's job is to predict where that Chainlink price will be at the close of the window.
What is the CLOB?
CLOB stands for Central Limit Order Book. Polymarket runs a CLOB where buyers post bids and sellers post asks at specific probability prices. Your bot interacts with this book directly via REST API , the same way a market maker would on a traditional exchange.
Price is NOT payout
A UP price of 0.58 means you pay $0.58 per share. If the market resolves UP, you receive $1.00 , a profit of $0.42. Confusing the price with the payout is the single most common beginner error and leads to wildly mis-sized orders.
You are ready for Step 2 when...
You can explain in plain language what a UP share costs, what it pays out, and why the bot must look up the active market ID on every cycle rather than hardcoding it. If those two points are clear, the rest of the tutorial will make sense.
Set Up Your Wallet, API Keys, and Dev Environment
A Polymarket bot needs three things before it can touch the API: a funded Polygon wallet, a set of API credentials tied to that wallet, and a local Python project with the right dependencies installed. This step walks through each of those in order. Skipping or rushing any one of them causes silent failures that are hard to debug later, so read the callouts carefully.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial. I'm on Step 02: Set Up Your Wallet, API Keys, and Dev Environment. Step goal: A Polymarket bot needs three things before it can touch the API: a funded Polygon wallet, a set of API credentials tied to that wallet, and a local Python project with the right dependencies installed. This step walks through each of those in order. Skipping or rushing any one of them causes silent failures that are hard to debug later, so read the callouts carefully. Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on. --- Specific sub-tasks to complete during this step: ## TASK 1: Prompt: Scaffold the Bot Project Structure Use this prompt after running the setup commands above. It tells the agent exactly what file structure you have and what each file should eventually contain. I have a Python project for a Polymarket trading bot with this file structure: polymarket-btc-bot/ ├── .env # API keys and private key (already filled in) ├── main.py # Entry point and execution loop ├── signal_engine.py # BTC price fetching and signal generation ├── orders.py # Order construction and CLOB submission ├── risk.py # Position sizing and loss limits └── utils.py # Shared helpers (logging, env loading, retries) Dependencies installed: py-clob-client-v2, python-dotenv, requests, schedule, web3 Please generate a utils.py file that: 1. Loads all environment variables from .env using python-dotenv 2. Sets up a logger that writes to both stdout and a file called bot.log 3. Includes a retry_with_backoff(fn, max_retries=5) helper that catches exceptions, waits 2^attempt seconds between retries, and logs each failure 4. Raises a clear error at startup if any required env var is missing Use Python 3.10+ syntax. Keep it clean and well-commented.
Funding Your Wallet on Polygon
Polymarket settles all trades in pUSD, an ERC-20 wrapper token on Polygon mainnet backed 1:1 by USDC. Every Polymarket order is denominated in pUSD, and your bot's bankroll is held as pUSD in your Polygon wallet. This is not Ethereum mainnet USDC. If you bridge or send raw USDC to your wallet on Ethereum mainnet and try to deposit it into Polymarket, it will not arrive , Polymarket trades on Polygon, and the collateral token is pUSD, not USDC directly.
The easiest path for beginners: buy USDC on Coinbase or Kraken and withdraw it directly to your MetaMask wallet on the Polygon network (it arrives as USDC.e on Polygon). Most major exchanges support Polygon withdrawals natively. Once you have USDC.e in your wallet, convert it to pUSD using Polymarket's official CollateralOnramp contract , approve the Onramp to spend your USDC.e, then call its wrap function and your balance becomes pUSD ready to trade. Polymarket also offers a deposit bridge that accepts any supported asset on any supported chain and auto-wraps to pUSD on arrival. If you already hold USDC on Ethereum mainnet, move it to Polygon first via the official Polygon Bridge at portal.polygon.technology, then wrap to pUSD.
Wrong network = locked funds
Sending USDC on Ethereum mainnet to your Polymarket deposit address does not work. Polymarket trades on Polygon and settles in pUSD; you need USDC.e on Polygon, which you then wrap to pUSD via the CollateralOnramp contract. Always confirm the network selector in your wallet shows 'Polygon' before sending. Double-check the chain ID: Polygon is 137.
Polymarket pays the gas , you only need pUSD
Polymarket runs a Relayer that sponsors gas (POL, the token Polygon migrated to from MATIC in September 2024) for every onchain operation routed through it: deposit-wallet deployment, pUSD approvals, CTF split/merge/redeem, and transfers between addresses. The CLOB itself is also gasless for traders , you sign orders off-chain, the matcher submits them onchain when they fill. For a standard CLOB trading bot like this one, you do not need to hold POL (or MATIC) in your wallet. The only token you need is pUSD.
Generating Polymarket API Credentials
Polymarket's CLOB API uses a two-layer auth system. Your wallet signs a message to prove ownership, and Polymarket returns an API key, secret, and passphrase tied to a *proxy wallet address* , not your main wallet address directly. This proxy address is what actually holds your CLOB positions.
To generate credentials: log into polymarket.com with your wallet, go to Settings, and find the API section. Click 'Generate API Key'. Store the key, secret, and passphrase immediately , Polymarket does not show the secret again after generation. Put them in a .env file, never in your source code.
If you regenerate your API key later, the old key stops working immediately. Any bot running with the old key will start throwing 401 errors. Update your .env file before restarting the bot.
# Polymarket CLOB API credentials
POLY_API_KEY=your_api_key_here
POLY_API_SECRET=your_api_secret_here
POLY_API_PASSPHRASE=your_passphrase_here
# Polymarket proxy/Safe wallet address (separate from your EOA above ,
# this is the address that actually holds your CLOB positions). Shown in
# the Polymarket UI under Settings → API. Pass it as `funder` when
# initializing ClobClient.
POLY_PROXY_ADDRESS=0xyour_proxy_wallet_address_here
# Signature type: 1 = POLY_PROXY (default for accounts created via the
# Polymarket UI), 2 = POLY_GNOSIS_SAFE (Safe-based smart wallets).
POLY_SIGNATURE_TYPE=1
# Your wallet private key (used to sign orders)
POLY_PRIVATE_KEY=0xyour_private_key_here
# Polygon RPC endpoint (use Alchemy or Infura for reliability)
POLYGON_RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/your_alchemy_key
# Optional: sponsored Chainlink API key for Polymarket RTDS (free public stream
# works without this; the sponsored key adds SLA for serious 15m crypto trading).
# Request a key at https://pm-ds-request.streams.chain.link/
CHAINLINK_RTDS_API_KEY=
# Paper trading mode , keep this true until you've validated end-to-end
PAPER_TRADING=true
# Create project directory
mkdir polymarket-btc-bot && cd polymarket-btc-bot
# Create a virtual environment
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Install dependencies
pip install py-clob-client-v2 # Official Polymarket CLOB SDK
pip install python-dotenv # Load .env credentials
pip install requests # HTTP calls to price feeds
pip install schedule # Lightweight job scheduler
pip install web3 # Polygon RPC interaction
# Create project files
touch .env main.py signal_engine.py orders.py risk.py utils.py
echo ".env" >> .gitignore
echo "venv/" >> .gitignore
echo "Project scaffolded. Fill in .env before running anything."
Never commit .env or your private key
Your private key gives anyone who has it full control of your wallet. The .gitignore step above is not optional. Before pushing to GitHub, run git status and confirm .env does not appear in the list of tracked files. Consider using a dedicated burner wallet for the bot with only the funds it needs.
You are ready for Step 3 when...
Running python utils.py prints your logger output without errors and confirms all required env vars are loaded. If you see a missing-variable error, fix the .env file before moving on , every subsequent step depends on those credentials being available.
Connect to the Polymarket CLOB API and Fetch Market Data
With credentials in place, the next task is initializing the CLOB client and getting a live price feed for the current 15-minute market. This step covers three things: initializing the CLOB client you will use to place orders in Step 5, resolving the currently active BTC Up/Down 15M market through Polymarket's public Gamma API, and streaming live UP and DOWN prices over the CLOB WebSocket with a REST fallback. By the end, your bot will be printing real-time prices for the live market.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial.
I'm on Step 03: Connect to the Polymarket CLOB API and Fetch Market Data.
Step goal: With credentials in place, the next task is initializing the CLOB client and getting a live price feed for the current 15-minute market. This step covers three things: initializing the CLOB client you will use to place orders in Step 5, resolving the currently active BTC Up/Down 15M market through Polymarket's public Gamma API, and streaming live UP and DOWN prices over the CLOB WebSocket with a REST fallback. By the end, your bot will be printing real-time prices for the live market.
Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on.
---
Specific sub-tasks to complete during this step:
## TASK 1: Resolve the current 15-minute market from Gamma
Add the following to utils.py:
1. A helper called current_cycle_start() that returns the unix-seconds start
of the current 15-minute cycle, aligned to wall-clock :00 :15 :30 :45 UTC.
Use int(time.time() // 900) * 900.
2. A function called get_active_btc_15m_market() that:
- Builds the slug f"btc-updown-15m-{current_cycle_start()}"
- GETs https://gamma-api.polymarket.com/events/slug/{slug}
- On 404, falls back to GET https://gamma-api.polymarket.com/markets
with slug_contains=btc-updown-15m, closed=false, limit=20, and picks
the row with the earliest endDate
- Parses outcomes and clobTokenIds, which Gamma returns as JSON strings
- Assigns up_token_id and down_token_id by matching the outcome name
("Up" / "Down"), not by array index
- Returns a dict with condition_id, question, slug, cycle_start, end_time,
up_token_id, down_token_id
- Returns None only if Gamma has not yet published this cycle's event
Use the existing logger from utils.py. Add type hints throughout.
## TASK 2: Stream live UP/DOWN prices from the CLOB WebSocket
Create a new module prices.py that uses asyncio and the websockets package:
1. stream_market_prices(token_ids, table) coroutine that:
- Connects to wss://ws-subscriptions-clob.polymarket.com/ws/market
- Sends {"type":"subscribe","channel":"market","assets_ids":token_ids}
- For each incoming message, updates the shared dict table[asset_id]
with best_bid, best_ask, mid, last_trade, and ts
- Handles three event types: book (use buys[0].price and sells[0].price),
price_change (read best_bid and best_ask from each price_changes item;
skip items that omit those because individual order prices are noisy),
and last_trade_price (update last_trade only)
- Auto-reconnects 5 seconds after any disconnect
2. poll_book_fallback(token_id, table, interval_s=3.0) coroutine that:
- GETs https://clob.polymarket.com/book?token_id={token_id} every 3s
- Only patches the table row if its ts is older than interval_s ago,
so the WebSocket remains the primary source
- Logs failures at DEBUG so they do not spam the console
3. A small main.py driver that runs all three tasks concurrently with
asyncio.run() and prints a UP/DOWN price snapshot every 2 seconds, so
you can visually confirm prices flow before moving on to Step 4.
Use type hints throughout.
Initializing the CLOB Client
The py-clob-client-v2 SDK wraps Polymarket's REST API into Python method calls. You initialize it once with your credentials and then reuse the client object for every API call in the bot. The client handles request signing automatically using your private key. Note: in October 2025 Polymarket released py-clob-client-v2 and deprecated the older py-clob-client. The V2 client exposes major classes (ClobClient, ApiCreds, OrderArgs, OrderType, BUY, SELL) at the top level , double-check the import paths against the V2 README if anything errors at install time.
The CLOB host for mainnet is https://clob.polymarket.com. There is no official sandbox environment for the CLOB in 2026 , you will use paper-trading mode (covered in Step 8) to test without real money. The chain ID for Polygon mainnet is 137.
One important detail: the ClobClient constructor accepts either L1 auth (private key only) or L2 auth (API key + secret + passphrase). For order placement you need L2 auth. For read-only market data queries you can use L1 or even unauthenticated calls. This tutorial uses L2 throughout to keep the auth layer consistent. You also pass two proxy-related arguments: funder (your Polymarket proxy/Safe address, the address that actually holds your CLOB positions , separate from the EOA private key you sign with) and signature_type (1 for the default POLY_PROXY, 2 if your account uses a Gnosis Safe smart wallet).
from py_clob_client_v2 import ClobClient
from py_clob_client_v2 import ApiCreds
from utils import load_env, get_logger
logger = get_logger(__name__)
def build_client() -> ClobClient:
env = load_env()
creds = ApiCreds(
api_key=env["POLY_API_KEY"],
api_secret=env["POLY_API_SECRET"],
api_passphrase=env["POLY_API_PASSPHRASE"],
)
client = ClobClient(
host="https://clob.polymarket.com",
key=env["POLY_PRIVATE_KEY"],
chain_id=137, # Polygon mainnet
creds=creds,
funder=env["POLY_PROXY_ADDRESS"], # the proxy that holds positions
signature_type=int(env.get("POLY_SIGNATURE_TYPE", "1")), # 1=POLY_PROXY, 2=POLY_GNOSIS_SAFE
)
logger.info("CLOB client initialized")
return client
Finding the Active BTC Up/Down 15M Market
The BTC Up/Down 15M market is not one permanent market. It is a series of markets, each lasting 15 minutes. Every 15 minutes the current market resolves and a new one opens with a fresh slug, condition ID, and pair of token IDs. Hardcoding any of those will work for exactly one cycle and silently fail for every cycle after.
Polymarket exposes these short-cycle markets through the public Gamma API at gamma-api.polymarket.com. The slug for the BTC 15-minute market follows the pattern btc-updown-15m-{unix-seconds}, where the timestamp is the cycle's start aligned to a wall-clock 15-minute boundary (00, 15, 30, 45 UTC). You compute the current cycle's slug locally, then fetch the event directly with GET /events/slug/{slug}. If Gamma lags the boundary by a few seconds (it sometimes does), fall back to a slug-prefix search on /markets?slug_contains=btc-updown-15m.
The event payload carries two parallel arrays you need for trading: outcomes (typically ["Up", "Down"]) and clobTokenIds. Both arrive serialized as JSON strings, so they need a parse step. Assign the UP and DOWN token IDs by matching the outcome name rather than by index position. That keeps your bot correct even if Polymarket changes ordering for a future market, and gives you a clean error to debug if the schema ever shifts.
import json
import time
import requests
from typing import Optional
GAMMA_HOST = "https://gamma-api.polymarket.com"
INTERVAL_S = 15 * 60 # 15-minute cycle
def current_cycle_start() -> int:
"""Wall-clock-aligned start of the current 15-min cycle (unix seconds)."""
return int(time.time() // INTERVAL_S) * INTERVAL_S
def get_active_btc_15m_market() -> Optional[dict]:
"""
Resolve the active BTC Up/Down 15M event from Gamma. Returns None only if
Gamma has not yet published this cycle's event.
"""
cycle_start = current_cycle_start()
slug = f"btc-updown-15m-{cycle_start}"
resp = requests.get(f"{GAMMA_HOST}/events/slug/{slug}", timeout=10)
if resp.status_code == 404:
# Gamma occasionally lags the boundary by a few seconds. Fall back
# to a slug-prefix search and take the earliest endDate.
fb = requests.get(
f"{GAMMA_HOST}/markets",
params={
"slug_contains": "btc-updown-15m",
"closed": "false",
"limit": 20,
},
timeout=10,
)
fb.raise_for_status()
rows = sorted(fb.json(), key=lambda m: m.get("endDate") or "")
market = next(
(m for m in rows if (m.get("slug") or "").startswith("btc-updown-15m")),
None,
)
if market is None:
return None
else:
resp.raise_for_status()
event = resp.json()
market = (event.get("markets") or [event])[0]
# Gamma serializes these fields as JSON strings.
outcomes = market["outcomes"]
if isinstance(outcomes, str):
outcomes = json.loads(outcomes)
token_ids = market["clobTokenIds"]
if isinstance(token_ids, str):
token_ids = json.loads(token_ids)
# Assign by outcome name, not by index. Polymarket does not guarantee
# ordering, and silent UP/DOWN swaps are the worst class of bug to debug.
by_outcome = {name.strip().lower(): tid for tid, name in zip(token_ids, outcomes)}
if "up" not in by_outcome or "down" not in by_outcome:
raise RuntimeError(f"Unexpected outcomes from Gamma: {outcomes}")
return {
"condition_id": market.get("conditionId") or market.get("condition_id"),
"question": market.get("question") or market.get("title", ""),
"slug": market["slug"],
"cycle_start": cycle_start,
"end_time": cycle_start + INTERVAL_S,
"up_token_id": by_outcome["up"],
"down_token_id": by_outcome["down"],
}
Streaming Live Prices via WebSocket
Once you have the UP and DOWN token IDs, Polymarket exposes a public market WebSocket at wss://ws-subscriptions-clob.polymarket.com/ws/market. Subscribe to the market channel with the asset IDs you care about, and the server pushes order-book and trade events for each as they happen. No authentication is required for market data; only orders need API keys.
The subscribe message is one line of JSON: {"type":"subscribe","channel":"market","assets_ids":[up_token_id, down_token_id]}. Three event types matter for pricing. A book event is a full top-of-book snapshot. It carries buys and sells arrays with the best price sitting at index zero of each side. A price_change event is a delta with a price_changes array; each item carries best_bid and best_ask fields directly, which is what you want. Ignore the individual order prices on the same payload, because they include resting orders deeper in the book and produce glitchy mid-price updates. A last_trade_price event reports the most recent trade for one token; combine it with the latest best_bid and best_ask from prior book or price_change updates.
In parallel with the WebSocket, run a 3-second REST poll against https://clob.polymarket.com/book?token_id={tid} as a safety net. If the WS stalls for any reason, the poller keeps prices fresh. The poller only overwrites a row whose websocket timestamp is older than the poll interval, so under normal conditions the WS remains the primary source and the REST call stays out of the way.
import asyncio
import json
import logging
import time
from typing import Dict, List
import requests
import websockets
logger = logging.getLogger(__name__)
CLOB_WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
CLOB_HOST = "https://clob.polymarket.com"
PriceTable = Dict[str, Dict[str, float]]
async def stream_market_prices(token_ids: List[str], table: PriceTable) -> None:
"""Subscribe to Polymarket's market WS and keep `table` in sync."""
while True:
try:
async with websockets.connect(CLOB_WS_URL, ping_interval=20) as ws:
await ws.send(json.dumps({
"type": "subscribe",
"channel": "market",
"assets_ids": token_ids,
}))
logger.info("Subscribed to %d token(s) on CLOB WS", len(token_ids))
async for raw in ws:
payload = json.loads(raw)
events = payload if isinstance(payload, list) else [payload]
for evt in events:
_apply_event(evt, table)
except (websockets.ConnectionClosed, OSError) as exc:
logger.warning("CLOB WS dropped (%s); reconnecting in 5s", exc)
await asyncio.sleep(5)
def _apply_event(evt: dict, table: PriceTable) -> None:
tid = evt.get("asset_id")
et = evt.get("event_type")
if not tid:
return
row = table.setdefault(tid, {})
if et == "book":
# Polymarket's book event uses 'buys' and 'sells'; best is at [0].
buys = evt.get("buys") or []
sells = evt.get("sells") or []
if buys:
row["best_bid"] = float(buys[0]["price"])
if sells:
row["best_ask"] = float(sells[0]["price"])
elif et == "price_change":
# Each price_changes item carries best_bid/best_ask directly.
# Skip items missing those, because individual order prices are noisy.
for change in evt.get("price_changes", []):
bb, ba = change.get("best_bid"), change.get("best_ask")
if bb is None or ba is None:
continue
try:
bb_f, ba_f = float(bb), float(ba)
except (TypeError, ValueError):
continue
if 0 <= bb_f <= 1 and 0 <= ba_f <= 1:
row["best_bid"], row["best_ask"] = bb_f, ba_f
elif et == "last_trade_price":
try:
row["last_trade"] = float(evt.get("price", 0))
except (TypeError, ValueError):
pass
if "best_bid" in row and "best_ask" in row:
row["mid"] = (row["best_bid"] + row["best_ask"]) / 2.0
row["ts"] = time.time()
async def poll_book_fallback(token_id: str, table: PriceTable, interval_s: float = 3.0) -> None:
"""Poll /book as a safety net when the WS is silent."""
while True:
try:
r = requests.get(
f"{CLOB_HOST}/book",
params={"token_id": token_id},
timeout=5,
)
r.raise_for_status()
book = r.json()
row = table.setdefault(token_id, {})
stale = (time.time() - row.get("ts", 0)) > interval_s
if stale:
# /book historically uses bids/asks; some proxies relay buys/sells.
buys = book.get("bids") or book.get("buys") or []
sells = book.get("asks") or book.get("sells") or []
if buys:
row["best_bid"] = float(buys[0]["price"])
if sells:
row["best_ask"] = float(sells[0]["price"])
if "best_bid" in row and "best_ask" in row:
row["mid"] = (row["best_bid"] + row["best_ask"]) / 2.0
row["ts"] = time.time()
except Exception as exc:
logger.debug("book poll for %s failed: %s", token_id[:10], exc)
await asyncio.sleep(interval_s)
def snapshot(table: PriceTable, market: dict) -> dict:
"""Build a per-cycle snapshot keyed by UP/DOWN."""
up = table.get(market["up_token_id"], {})
down = table.get(market["down_token_id"], {})
keys = ("best_bid", "best_ask", "mid", "last_trade")
return {
"slug": market["slug"],
"up": {k: up.get(k) for k in keys},
"down": {k: down.get(k) for k in keys},
}
import asyncio
from utils import get_active_btc_15m_market
from prices import stream_market_prices, poll_book_fallback, snapshot
async def watch_current_market() -> None:
market = get_active_btc_15m_market()
if market is None:
raise RuntimeError(
"No active BTC 15M market right now. Gamma may briefly lag the "
"cycle boundary; retry in a few seconds."
)
table: dict = {}
tasks = [
asyncio.create_task(stream_market_prices(
[market["up_token_id"], market["down_token_id"]],
table,
)),
asyncio.create_task(poll_book_fallback(market["up_token_id"], table)),
asyncio.create_task(poll_book_fallback(market["down_token_id"], table)),
]
for _ in range(15): # 30 seconds of live updates
await asyncio.sleep(2)
print(snapshot(table, market))
for t in tasks:
t.cancel()
if __name__ == "__main__":
asyncio.run(watch_current_market())
Rate limits are strict
Polymarket's CLOB REST endpoints rate-limit per IP. Polling /book more than once every few seconds will trigger 429 errors. That is why this tutorial uses the WebSocket as the primary feed and the REST poll only as a 3-second fallback. Always cache market metadata between cycles rather than re-fetching it on every function call, and never run more than one bot instance against the same IP without a proxy.
You are ready for Step 4 when...
Running main.py prints a snapshot every two seconds whose up.mid and down.mid both sit between 0.01 and 0.99 and sum to approximately 1.0 (minus the spread). If they do not sum near 1.0, get_active_btc_15m_market() likely failed to assign UP and DOWN correctly. Inspect the outcomes Gamma returned before debugging anything else.
Build the Signal Engine , Predicting BTC Up or Down
The signal engine is the brain of the bot. It takes raw market and price data as input and outputs a trading decision: buy UP, buy DOWN, or do nothing. This step builds a simple but functional signal for the BTC Up/Down 15M market using the current BTC price compared to the price 15 minutes ago, combined with the market's implied probability. The goal is a signal that is explainable, testable, and easy to improve later.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial.
I'm on Step 04: Build the Signal Engine , Predicting BTC Up or Down.
Step goal: The signal engine is the brain of the bot. It takes raw market and price data as input and outputs a trading decision: buy UP, buy DOWN, or do nothing. This step builds a simple but functional signal for the BTC Up/Down 15M market using the current BTC price compared to the price 15 minutes ago, combined with the market's implied probability. The goal is a signal that is explainable, testable, and easy to improve later.
Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on.
---
Specific sub-tasks to complete during this step:
## TASK 1: Prompt: Extend the Signal with Volume and Spread Filters
Use this after the basic signal is working. It asks the agent to add two additional filters that reduce false signals: a spread filter (don't trade when the order book spread is too wide) and a time-to-expiry filter (don't trade in the last 2 minutes before resolution).
I have a working signal function in signal_engine.py for my Polymarket BTC Up/Down 15M bot.
The function is called generate_signal(up_mid: float) -> Signal | None.
Please extend it with two additional pre-signal filters:
1. SPREAD FILTER
- Add a parameter: spread (float) , the current UP order book spread (ask - bid)
- If spread > 0.06 (6 cents), return None immediately with a log message:
'Spread too wide ({spread:.3f}) , skipping to avoid slippage'
- This protects against entering illiquid markets where we'd lose to slippage
2. TIME-TO-EXPIRY FILTER
- Add a parameter: seconds_to_expiry (int)
- If seconds_to_expiry < 120 (less than 2 minutes to resolution), return None:
'Too close to expiry ({seconds_to_expiry}s) , skipping'
- Late entries have high adverse selection risk on 15M markets
Update the Signal dataclass to include: spread (float) and seconds_to_expiry (int)
so callers can log the full context of why a signal was generated.
Update the function signature to:
generate_signal(up_mid: float, spread: float, seconds_to_expiry: int) -> Signal | None
Keep all existing logic intact. Add docstrings explaining each filter.
What Makes a Good Signal for BTC 15M
A signal is a function that maps observations to a trading action. For the BTC 15M market, the most natural observation is the direction and momentum of BTC's price in the minutes leading up to the resolution window. If BTC has been rising steadily for the past 10 minutes, an UP bet has a higher expected value than the market's current implied probability might suggest.
The signal in this step uses two inputs: the *price delta* (current BTC price minus the price 15 minutes ago, expressed as a percentage) and the *market mid price* for UP shares. If the price delta is positive and the UP mid is below a threshold (meaning the market is underpricing the upward momentum), the signal fires UP. The reverse logic applies for DOWN.
Price source matters here. The BTC 15M market resolves against Chainlink's BTC/USD data stream, so the bot subscribes to Polymarket's Real-Time Data Socket (RTDS) for the same Chainlink feed (crypto_prices_chainlink with {"symbol":"btc/usd"}). That eliminates basis risk between your signal and the resolution. If RTDS is unreachable, the code falls over automatically to Coinbase's free Advanced Trade WebSocket (ticker channel on BTC-USD), which tracks Chainlink closely. The starter project's docs/price-feeds/ folder has the full protocol references for both.
This is a simple momentum signal. It will not win every trade. The goal at this stage is to have a *working, testable* signal that you can improve over time. A bot with a mediocre signal and good risk management beats a bot with a great signal and no risk controls every time.
import json
import os
import threading
import time
from collections import deque
from websocket import WebSocketApp
from utils import get_logger
logger = get_logger(__name__)
RTDS_URL = "wss://ws-live-data.polymarket.com"
COINBASE_URL = "wss://advanced-trade-ws.coinbase.com"
# Shared price state. Two WebSocket threads write here; the signal loop reads.
_lock = threading.Lock()
_history: "deque[tuple[float, float]]" = deque() # (price, ts)
_latest_price: float | None = None
_active_source: str = "(none)"
_rtds_alive = threading.Event()
STARTUP_TIMEOUT_S = 15
_HISTORY_MAX_AGE_S = 1800
def _push_tick(price: float, source: str) -> None:
global _latest_price, _active_source
now = time.time()
with _lock:
_latest_price = price
_active_source = source
_history.append((price, now))
cutoff = now - _HISTORY_MAX_AGE_S
while _history and _history[0][1] < cutoff:
_history.popleft()
def get_latest_price() -> float | None:
with _lock:
return _latest_price
def get_price_delta_pct(lookback_seconds: int = 900) -> float | None:
now = time.time()
cutoff = now - lookback_seconds
with _lock:
if not _history:
return None
old = [(p, ts) for p, ts in _history if ts <= cutoff]
if not old:
return None
return ((_history[-1][0] - old[-1][0]) / old[-1][0]) * 100
# ---- RTDS (Chainlink) , primary ----
def _rtds_run() -> None:
def _on_open(ws):
sub = {"action": "subscribe", "subscriptions": [{
"topic": "crypto_prices_chainlink", "type": "*",
"filters": json.dumps({"symbol": "btc/usd"}),
}]}
ws.send(json.dumps(sub))
# RTDS keep-alive: send the literal text PING every 5 seconds.
def _ping():
while ws.keep_running:
try: ws.send("PING")
except Exception: return
time.sleep(5)
threading.Thread(target=_ping, daemon=True).start()
logger.info("RTDS subscribed: crypto_prices_chainlink btc/usd")
def _on_message(_ws, raw):
if raw == "PONG": return
try: msg = json.loads(raw)
except Exception: return
if msg.get("topic") != "crypto_prices_chainlink": return
v = (msg.get("payload") or {}).get("value")
if v is None: return
_push_tick(float(v), "rtds-chainlink")
if not _rtds_alive.is_set():
_rtds_alive.set()
logger.info(f"RTDS first BTC tick: ${float(v):,.2f}")
while True:
ws = WebSocketApp(RTDS_URL, on_open=_on_open, on_message=_on_message)
ws.run_forever(ping_interval=0)
logger.warning("RTDS disconnected; reconnect in 2s")
time.sleep(2)
# ---- Coinbase , fallback ----
def _coinbase_run() -> None:
def _on_open(ws):
ws.send(json.dumps({
"type": "subscribe", "product_ids": ["BTC-USD"], "channel": "ticker",
}))
logger.info("Coinbase WS subscribed: ticker BTC-USD")
def _on_message(_ws, raw):
try: msg = json.loads(raw)
except Exception: return
if msg.get("channel") != "ticker": return
for ev in msg.get("events", []):
for t in ev.get("tickers", []):
if t.get("product_id") == "BTC-USD":
_push_tick(float(t["price"]), "coinbase")
while True:
ws = WebSocketApp(COINBASE_URL, on_open=_on_open, on_message=_on_message)
ws.run_forever(ping_interval=20, ping_timeout=10)
logger.warning("Coinbase WS disconnected; reconnect in 2s")
time.sleep(2)
def start_price_feed() -> None:
"""Start RTDS Chainlink (primary). Fall over to Coinbase if RTDS is silent
for STARTUP_TIMEOUT_S. RTDS continues retrying in the background and becomes
the active source again once it recovers."""
threading.Thread(target=_rtds_run, daemon=True).start()
def _watch():
if _rtds_alive.wait(timeout=STARTUP_TIMEOUT_S):
return
logger.warning(f"RTDS silent for {STARTUP_TIMEOUT_S}s , failing over to Coinbase")
threading.Thread(target=_coinbase_run, daemon=True).start()
threading.Thread(target=_watch, daemon=True).start()
logger.info("Price feed: RTDS Chainlink primary, Coinbase ready for failover")
Why RTDS Chainlink, not Coinbase?
Polymarket's BTC Up/Down 15M market resolves against Chainlink's BTC/USD data stream. Subscribing to the SAME stream via Polymarket's RTDS (wss://ws-live-data.polymarket.com, topic crypto_prices_chainlink, filter {"symbol":"btc/usd"}) means your bot's view of "the price" matches the value the UMA resolution proposer will read at expiry. Coinbase WS is the fallback , it tracks Chainlink closely (sub-second drift in normal conditions) but is not the source of truth for resolution. See docs/price-feeds/polymarket-rtds-reference.md for full protocol details, and remember the 5-second PING keep-alive , RTDS will drop you after about 10 seconds of silence.
from dataclasses import dataclass
@dataclass
class Signal:
direction: str # 'UP' or 'DOWN'
confidence: float # 0.0 to 1.0
up_mid: float # current UP market price
price_delta_pct: float
# Tunable thresholds , adjust based on backtesting
MOMENTUM_THRESHOLD_PCT = 0.15 # BTC must have moved at least 0.15% in 15 min
EDGE_THRESHOLD = 0.05 # We need at least 5% edge vs market price
MIN_CONFIDENCE = 0.55 # Don't trade below this confidence
def generate_signal(up_mid: float) -> Signal | None:
"""
Generate a UP/DOWN trading signal for the BTC Up/Down 15M market.
Returns None if no tradeable edge is detected.
"""
delta = get_price_delta_pct(lookback_seconds=900)
if delta is None:
logger.info("Insufficient price history , skipping signal")
return None
if delta > MOMENTUM_THRESHOLD_PCT:
# BTC is rising , UP is more likely than the market implies
implied_up_prob = up_mid
our_up_prob = min(0.95, implied_up_prob + abs(delta) * 0.1)
edge = our_up_prob - implied_up_prob
if edge >= EDGE_THRESHOLD:
confidence = min(1.0, 0.5 + edge)
if confidence >= MIN_CONFIDENCE:
return Signal("UP", confidence, up_mid, delta)
elif delta < -MOMENTUM_THRESHOLD_PCT:
# BTC is falling , DOWN is more likely
implied_down_prob = 1.0 - up_mid
our_down_prob = min(0.95, implied_down_prob + abs(delta) * 0.1)
edge = our_down_prob - implied_down_prob
if edge >= EDGE_THRESHOLD:
confidence = min(1.0, 0.5 + edge)
if confidence >= MIN_CONFIDENCE:
return Signal("DOWN", confidence, up_mid, delta)
logger.info(f"No edge detected. delta={delta:.3f}%, up_mid={up_mid:.3f}")
return None
This signal is a starting point, not a finished strategy
The momentum signal in this step will not produce consistent profits out of the box. It is designed to be correct in structure so you can backtest it, tune the thresholds, and replace the logic with something better. Never run an untested signal with real money. Step 8 covers paper trading before going live.
Place and Manage Orders on the CLOB
With a signal in hand, the bot now needs to translate that signal into an actual order on the Polymarket CLOB. This step covers how to construct a limit order payload, submit it to the API, poll for fill status, and cancel open orders when the market is about to expire or the signal reverses. Order management is where most beginner bots break , the details matter.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial.
I'm on Step 05: Place and Manage Orders on the CLOB.
Step goal: With a signal in hand, the bot now needs to translate that signal into an actual order on the Polymarket CLOB. This step covers how to construct a limit order payload, submit it to the API, poll for fill status, and cancel open orders when the market is about to expire or the signal reverses. Order management is where most beginner bots break , the details matter.
Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on.
---
Specific sub-tasks to complete during this step:
## TASK 1: Prompt: Add Open Position Tracking
Use this after the basic order placement and polling code is working. The bot needs to track which orders are open so it does not double-enter a position on the next cycle.
I have a Polymarket trading bot with working order placement and cancellation in orders.py.
The functions are: place_limit_order(), poll_order_status(), cancel_order().
Please add an OpenPositionTracker class to orders.py that:
1. Maintains an in-memory dict of open orders keyed by order_id
Each entry stores: { order_id, direction, token_id, size_pusd, limit_price, placed_at }
2. Has these methods:
- add(order_id, direction, token_id, size_pusd, limit_price) -> None
- remove(order_id) -> None
- has_open_position(direction: str) -> bool # True if any open order in that direction
- get_all() -> list[dict]
- cancel_all_open(client: ClobClient) -> int # cancels all, returns count cancelled
3. In cancel_all_open, call cancel_order() for each open order and remove it from tracking
regardless of whether the cancel succeeded (to avoid stuck state)
4. Log every add/remove/cancel_all action at INFO level with the order details
This class will be used in the main loop to prevent double-entering positions.
Instantiate it as a module-level singleton: position_tracker = OpenPositionTracker()
How CLOB Orders Work on Polymarket
A limit order on the Polymarket CLOB specifies a token ID (UP or DOWN), a side (BUY or SELL), a price (the probability you are willing to pay, between 0.01 and 0.99), and a size (in pUSD). The order sits in the book until it is matched with a counterparty or you cancel it.
For the BTC 15M market, you almost always want to BUY the direction you predict , buy UP shares if you think BTC goes up, buy DOWN shares if you think it goes down. Selling shares you do not own (shorting) is possible but adds complexity. This tutorial sticks to buy-only orders.
Tick size matters. The CLOB enforces a minimum price increment (tick size) per market. Submitting a price that is not a valid tick will result in a rejection. For most Polymarket markets in 2026, the tick size is 0.01. Always round your limit price to two decimal places before submitting.
from py_clob_client_v2 import ClobClient
from py_clob_client_v2 import OrderArgs, OrderType
from py_clob_client_v2 import BUY
from signal_engine import Signal
from utils import get_logger
import math
logger = get_logger(__name__)
def round_to_tick(price: float, tick: float = 0.01) -> float:
"""Round price to the nearest valid tick size."""
return round(math.floor(price / tick) * tick, 10)
def place_limit_order(
client: ClobClient,
signal: Signal,
market: dict,
size_pusd: float,
) -> dict | None:
"""
Place a limit order based on the signal direction.
Returns the order response dict or None on failure.
"""
token_id = (
market["up_token_id"] if signal.direction == "UP"
else market["down_token_id"]
)
# For UP: buy at or just below the ask to get filled quickly
# For DOWN: the DOWN price is 1 - up_mid
if signal.direction == "UP":
limit_price = round_to_tick(signal.up_mid + 0.01)
else:
limit_price = round_to_tick((1.0 - signal.up_mid) + 0.01)
limit_price = min(limit_price, 0.99) # Never pay more than $0.99
order_args = OrderArgs(
token_id=token_id,
price=limit_price,
size=round(size_pusd, 2),
side=BUY,
)
try:
resp = client.create_and_post_order(order_args)
logger.info(
f"Order placed: {signal.direction} @ {limit_price} "
f"size={size_pusd} order_id={resp.get('orderID')}"
)
return resp
except Exception as e:
logger.error(f"Order placement failed: {e}")
return None
import time
def poll_order_status(client: ClobClient, order_id: str, timeout_seconds: int = 60) -> str:
"""
Poll until order is FILLED, CANCELLED, or timeout is reached.
Returns the final status string.
"""
start = time.time()
while time.time() - start < timeout_seconds:
try:
order = client.get_order(order_id)
status = order.get("status", "UNKNOWN")
logger.debug(f"Order {order_id} status: {status}")
if status in ("MATCHED", "FILLED"):
logger.info(f"Order {order_id} filled.")
return "FILLED"
if status in ("CANCELLED", "EXPIRED"):
logger.info(f"Order {order_id} cancelled/expired.")
return "CANCELLED"
except Exception as e:
logger.warning(f"Status poll error: {e}")
time.sleep(5)
logger.warning(f"Order {order_id} timed out , cancelling.")
cancel_order(client, order_id)
return "TIMEOUT"
def cancel_order(client: ClobClient, order_id: str) -> bool:
"""Cancel a specific open order. Returns True on success."""
try:
client.cancel(order_id)
logger.info(f"Order {order_id} cancelled.")
return True
except Exception as e:
logger.error(f"Cancel failed for {order_id}: {e}")
return False
Below-minimum orders fail silently
Some versions of the Polymarket SDK return a success response even when an order is rejected for being below the minimum size. Always check that your size_pusd is at least $1.00 before calling place_limit_order. Log the full response dict, not just the order ID.
You are ready for Step 6 when...
You can place a test order, see it appear in your Polymarket account's open orders, poll its status, and cancel it programmatically. Verify by checking the Polymarket UI , the order should appear and disappear as your code runs.
Add Risk Management and Bankroll Controls
A bot without risk controls is a bot that will eventually blow up its account. This step adds three layers of protection: per-trade size limits that scale with your bankroll, a maximum open-position cap so you are never overexposed at once, and a daily loss circuit-breaker that shuts the bot down if it loses too much in a single day. These controls are not optional , they are what separates a bot you can run safely from one that drains your wallet overnight.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial. I'm on Step 06: Add Risk Management and Bankroll Controls. Step goal: A bot without risk controls is a bot that will eventually blow up its account. This step adds three layers of protection: per-trade size limits that scale with your bankroll, a maximum open-position cap so you are never overexposed at once, and a daily loss circuit-breaker that shuts the bot down if it loses too much in a single day. These controls are not optional , they are what separates a bot you can run safely from one that drains your wallet overnight. Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on. --- Specific sub-tasks to complete during this step: ## TASK 1: Prompt: Wire the Risk Manager into the Main Loop Use this after the RiskManager class is written. It asks the agent to integrate the risk checks into the main trading cycle so every trade is gated by the risk controls. I have a RiskManager class in risk.py with these methods: - kelly_size(edge: float, odds: float) -> float - can_trade(proposed_size: float) -> tuple[bool, str] - record_trade_open(size: float) - record_trade_close(size: float, pnl: float) I also have in orders.py: - place_limit_order(client, signal, market, size_pusd) -> dict | None - poll_order_status(client, order_id, timeout_seconds) -> str - position_tracker (OpenPositionTracker singleton) And in signal_engine.py: - generate_signal(up_mid, spread, seconds_to_expiry) -> Signal | None - Signal dataclass with fields: direction, confidence, up_mid, price_delta_pct, spread, seconds_to_expiry Please write a function called run_trading_cycle(client, risk_manager, market) in main.py that: 1. Calls get_market_snapshot(client) to get up_mid, spread, seconds_to_expiry 2. Calls generate_signal() , if None, logs 'No signal' and returns 3. Checks position_tracker.has_open_position(signal.direction) , if True, logs 'Already in position' and returns 4. Calculates edge as (signal.confidence - signal.up_mid) for UP or (signal.confidence - (1 - signal.up_mid)) for DOWN 5. Calculates odds as (1 / signal.confidence) - 1 6. Calls risk_manager.kelly_size(edge, odds) to get size_pusd 7. Calls risk_manager.can_trade(size_pusd) , if False, logs the reason and returns 8. Calls place_limit_order() and records the trade with risk_manager.record_trade_open() 9. Calls poll_order_status() and logs the result 10. Calls risk_manager.record_trade_close() with estimated PnL (size * edge if filled, -0 if cancelled) Add clear logging at each decision point. Handle all exceptions without crashing the loop.
The Three Layers of Risk Control
**Per-trade sizing** determines how much pUSD to risk on each individual trade. The standard approach is the Kelly Criterion, which sizes bets proportionally to your edge. For beginners, use a *fractional Kelly* , typically 25% of the full Kelly bet , to reduce variance while you validate the signal. The formula is: bet_size = bankroll * (edge / odds) * kelly_fraction.
**Position caps** limit how much of your bankroll can be in open positions at any one time. On the BTC 15M market, positions resolve every 15 minutes, so you rarely need more than one open position at a time. Cap total open exposure at 10-20% of your bankroll. If the cap is hit, the bot skips the cycle rather than adding more exposure.
**Daily loss limits** are a circuit-breaker. If the bot loses more than a set percentage of the starting bankroll in a single day, it stops trading and sends an alert. This prevents a bad signal or a market anomaly from wiping out the account before you notice. Set the limit at 5-10% of your daily starting balance.
from utils import get_logger
import time
logger = get_logger(__name__)
class RiskManager:
def __init__(
self,
starting_bankroll: float,
kelly_fraction: float = 0.25,
max_open_exposure_pct: float = 0.15,
daily_loss_limit_pct: float = 0.07,
min_trade_pusd: float = 1.0,
max_trade_pusd: float = 50.0,
):
self.starting_bankroll = starting_bankroll
self.current_bankroll = starting_bankroll
self.kelly_fraction = kelly_fraction
self.max_open_exposure = starting_bankroll * max_open_exposure_pct
self.daily_loss_limit = starting_bankroll * daily_loss_limit_pct
self.min_trade_pusd = min_trade_pusd
self.max_trade_pusd = max_trade_pusd
self.daily_pnl = 0.0
self.day_start = time.strftime("%Y-%m-%d")
self.open_exposure = 0.0
self.circuit_open = False
def _reset_daily_if_needed(self):
today = time.strftime("%Y-%m-%d")
if today != self.day_start:
logger.info(f"New day. Resetting daily PnL. Previous: {self.daily_pnl:.2f}")
self.daily_pnl = 0.0
self.day_start = today
self.circuit_open = False
def kelly_size(self, edge: float, odds: float) -> float:
"""Calculate fractional Kelly bet size in pUSD."""
if odds <= 0 or edge <= 0:
return 0.0
full_kelly = self.current_bankroll * (edge / odds)
size = full_kelly * self.kelly_fraction
return max(self.min_trade_pusd, min(size, self.max_trade_pusd))
def can_trade(self, proposed_size: float) -> tuple[bool, str]:
"""Returns (True, '') if trade is allowed, else (False, reason)."""
self._reset_daily_if_needed()
if self.circuit_open:
return False, "Daily loss circuit-breaker is open"
if self.daily_pnl <= -self.daily_loss_limit:
self.circuit_open = True
logger.warning("Daily loss limit hit. Circuit-breaker opened.")
return False, "Daily loss limit reached"
if self.open_exposure + proposed_size > self.max_open_exposure:
return False, f"Open exposure cap reached ({self.open_exposure:.2f} open)"
return True, ""
def record_trade_open(self, size: float):
self.open_exposure += size
logger.info(f"Trade opened. Open exposure: {self.open_exposure:.2f}")
def record_trade_close(self, size: float, pnl: float):
self.open_exposure = max(0.0, self.open_exposure - size)
self.daily_pnl += pnl
self.current_bankroll += pnl
logger.info(f"Trade closed. PnL: {pnl:.2f}. Daily PnL: {self.daily_pnl:.2f}")
Start with 10% Kelly, not 25%
The 25% Kelly fraction in the code is already conservative, but while you are validating a new signal, drop it to 10%. You will make less money on winning trades, but you will also lose much less while you confirm the signal actually has edge. Increase it only after 50+ paper trades show positive expected value.
You are ready for Step 7 when...
Running run_trading_cycle() with a mock signal correctly gates on the circuit-breaker and exposure cap. Simulate a daily loss by setting daily_pnl to a large negative number and confirm the bot refuses to trade. Then reset and confirm it trades normally.
Automate the Execution Loop and Schedule Runs
The bot's individual components are working , now you need to tie them together into a loop that runs automatically every 15 minutes without manual intervention. This step wraps the fetch, signal, and order cycle in a scheduler, handles graceful shutdown on errors, and sets up the logging infrastructure you will need to monitor the bot remotely. After this step, the bot can run unattended.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial.
I'm on Step 07: Automate the Execution Loop and Schedule Runs.
Step goal: The bot's individual components are working , now you need to tie them together into a loop that runs automatically every 15 minutes without manual intervention. This step wraps the fetch, signal, and order cycle in a scheduler, handles graceful shutdown on errors, and sets up the logging infrastructure you will need to monitor the bot remotely. After this step, the bot can run unattended.
Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on.
---
Specific sub-tasks to complete during this step:
## TASK 1: Prompt: Add Market-Synchronized Scheduling
Use this to replace the fixed 15-minute interval with a schedule that aligns to the actual market resolution times, so the bot always runs at the start of a new market window rather than at a fixed offset from startup.
My Polymarket BTC 15M bot currently uses schedule.every(15).minutes.do(cycle).
This is not synchronized to the actual market windows , if I start the bot at 12:07,
it runs at 12:07, 12:22, 12:37, etc., which may miss the start of each new market.
Please rewrite the scheduling logic in main.py to:
1. After each cycle completes, call get_active_btc_15m_market() to get the current
market's end_date_iso (ISO 8601 UTC timestamp)
2. Parse end_date_iso using datetime.fromisoformat() and calculate seconds_until_expiry
3. Schedule the next cycle to run at: seconds_until_expiry + 5 seconds
(the +5 gives the new market a moment to appear in the API after the old one resolves)
4. Use threading.Timer(delay_seconds, cycle) instead of the schedule library
so each cycle dynamically schedules the next one
5. If get_active_btc_15m_market() returns None, fall back to a 60-second retry
6. Log the calculated delay before sleeping: 'Next cycle in {delay:.0f}s (at {next_run_utc})'
7. Keep the SIGINT/SIGTERM shutdown handling intact , the Timer should be cancelled
on shutdown
Use only stdlib modules: threading, datetime, time, signal. No new pip installs.
Choosing a Scheduling Approach
There are two common ways to schedule a Python bot: the schedule library (simple, synchronous, runs in a single thread) and asyncio with asyncio.sleep (async, better for I/O-heavy bots). For this tutorial, the schedule library is the right choice. It is easy to understand, easy to debug, and more than fast enough for a 15-minute cycle.
The main loop runs schedule.run_pending() in a tight while-True loop with a short sleep. Every 15 minutes, the scheduler fires run_trading_cycle(). Between cycles, the loop checks for shutdown signals and logs a heartbeat so you know the process is still alive.
One important detail for the BTC 15M market: you want to trigger the cycle *at the start of each 15-minute window*, not on a fixed clock interval from when you started the bot. Use the market's end_date_iso field to calculate how many seconds remain and schedule the next cycle accordingly. This keeps the bot synchronized with the market's actual resolution cadence.
import schedule
import time
import signal as sys_signal
import sys
# build_client and run_trading_cycle are defined in this file (added in Steps 3 and 6).
from risk import RiskManager
from utils import get_logger, get_active_btc_15m_market
logger = get_logger("main")
# Global shutdown flag
_shutdown = False
def handle_shutdown(signum, frame):
global _shutdown
logger.info("Shutdown signal received. Finishing current cycle...")
_shutdown = True
def main():
global _shutdown
# Register SIGINT and SIGTERM handlers for clean shutdown
sys_signal.signal(sys_signal.SIGINT, handle_shutdown)
sys_signal.signal(sys_signal.SIGTERM, handle_shutdown)
logger.info("=== Polymarket BTC 15M Bot Starting ===")
client = build_client()
risk = RiskManager(
starting_bankroll=50.0, # Adjust to your actual pUSD balance
kelly_fraction=0.10, # Conservative for first run
daily_loss_limit_pct=0.05,
)
def cycle():
if _shutdown:
return
try:
market = get_active_btc_15m_market()
if market is None:
logger.warning("No active BTC 15M market found. Skipping cycle.")
return
logger.info(f"Cycle start. Market: {market['question']}")
run_trading_cycle(client, risk, market)
except Exception as e:
logger.error(f"Unhandled error in cycle: {e}", exc_info=True)
# Run immediately on start, then every 15 minutes
cycle()
schedule.every(15).minutes.do(cycle)
logger.info("Scheduler running. Press Ctrl+C to stop.")
while not _shutdown:
schedule.run_pending()
time.sleep(10) # Check every 10 seconds
logger.info("Bot shut down cleanly.")
sys.exit(0)
if __name__ == "__main__":
main()
[Unit]
Description=Polymarket BTC 15M Trading Bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/polymarket-btc-bot
ExecStart=/home/ubuntu/polymarket-btc-bot/venv/bin/python main.py
Restart=on-failure
RestartSec=30
StandardOutput=journal
StandardError=journal
EnvironmentFile=/home/ubuntu/polymarket-btc-bot/.env
[Install]
WantedBy=multi-user.target
Run on a VPS, not your laptop
A bot running on your laptop stops when you close the lid or lose WiFi. For reliable 24/7 operation, deploy to a cheap VPS (DigitalOcean, Hetzner, or AWS Lightsail). A $5/month instance is more than enough for this bot. Use the systemd service file above to keep it running.
You are ready for Step 8 when...
The bot runs for at least two full 15-minute cycles without crashing, logs a heartbeat between cycles, and shuts down cleanly on Ctrl+C. Check bot.log to confirm cycle start and end times are being recorded correctly.
Test, Debug, and Go Live Safely
The bot is built. Before committing real pUSD, you need to validate it in paper-trading mode, review the most common Polymarket API errors you will encounter in 2026, and run through a pre-launch checklist. This step covers all three. Going live without this step is how beginners lose their entire test bankroll in the first hour.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial.
I'm on Step 08: Test, Debug, and Go Live Safely.
Step goal: The bot is built. Before committing real pUSD, you need to validate it in paper-trading mode, review the most common Polymarket API errors you will encounter in 2026, and run through a pre-launch checklist. This step covers all three. Going live without this step is how beginners lose their entire test bankroll in the first hour.
Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on.
---
Specific sub-tasks to complete during this step:
## TASK 1: Prompt: Add a Pre-Launch Health Check
Run this prompt to generate a health check script you can run before going live. It validates credentials, confirms the market is reachable, checks your pUSD balance, and confirms the risk manager is configured correctly.
I am about to go live with a Polymarket BTC Up/Down 15M trading bot written in Python.
Before starting the bot with real money, I want to run a health check script.
Please write a standalone Python script called health_check.py that:
1. CREDENTIALS CHECK
- Loads .env and confirms POLY_API_KEY, POLY_API_SECRET, POLY_API_PASSPHRASE,
POLY_PRIVATE_KEY, and POLYGON_RPC_URL are all present and non-empty
- Initializes the ClobClient and calls client.get_ok() to confirm API connectivity
- Prints PASS or FAIL with a reason for each check
2. MARKET CHECK
- Calls get_active_btc_15m_market() and confirms a market is returned
- Prints the market question and seconds until expiry
- Confirms up_token_id and down_token_id are present
3. BALANCE CHECK
- Calls client.get_balance() or equivalent to fetch pUSD balance
- Warns if balance is below $5 (too low to trade meaningfully)
- Prints the current balance
4. RISK CONFIG CHECK
- Instantiates RiskManager with the same params as main.py
- Prints the configured daily loss limit, max open exposure, and kelly fraction
- Confirms kelly_size(edge=0.08, odds=1.5) returns a value between min and max trade size
5. SUMMARY
- Prints a final GO / NO-GO verdict
- If any check failed, prints which ones and exits with code 1
- If all passed, prints 'All checks passed. Safe to start the bot.' and exits with code 0
Use the existing utils.py, risk.py, and utils.get_active_btc_15m_market().
Paper Trading Mode
Paper trading means running the full bot logic , signal generation, order sizing, risk checks , but replacing the actual order submission with a simulated fill. The bot logs what it *would* have done and tracks a virtual PnL. This lets you validate the signal and the loop without risking real money.
Implement paper trading with a single environment variable: PAPER_TRADING=true. When this flag is set, place_limit_order() skips the API call and instead simulates a fill at the current mid price. The position tracker and risk manager still update normally, so the paper-trading run exercises all the same code paths as a live run.
Run paper trading for at least 20-30 cycles (5-7 hours for a 15-minute market) before going live. Track the simulated PnL in a CSV file. If the paper PnL is consistently negative, the signal needs work before you risk real money.
import os
import csv
from datetime import datetime, timezone
PAPER_TRADING = os.getenv("PAPER_TRADING", "false").lower() == "true"
PAPER_LOG_FILE = "paper_trades.csv"
def place_limit_order(client, signal, market, size_pusd):
if PAPER_TRADING:
simulated_fill_price = signal.up_mid if signal.direction == "UP" else (1 - signal.up_mid)
simulated_pnl_if_correct = size_pusd * (1 - simulated_fill_price) / simulated_fill_price
row = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"direction": signal.direction,
"fill_price": simulated_fill_price,
"size_pusd": size_pusd,
"confidence": signal.confidence,
"price_delta_pct": signal.price_delta_pct,
"potential_pnl": round(simulated_pnl_if_correct, 4),
"market": market["question"],
}
_append_paper_log(row)
logger.info(f"[PAPER] Simulated {signal.direction} fill @ {simulated_fill_price:.3f}")
return {"orderID": "PAPER-" + datetime.now().strftime("%H%M%S"), "status": "FILLED"}
# ... real order placement code below ...
def _append_paper_log(row: dict):
file_exists = os.path.exists(PAPER_LOG_FILE)
with open(PAPER_LOG_FILE, "a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=row.keys())
if not file_exists:
writer.writeheader()
writer.writerow(row)
Common Polymarket API Errors in 2026
**401 Unauthorized**: Your API key is wrong, expired, or tied to a different wallet than the private key you are using. Regenerate the key in the Polymarket UI and update .env. Restart the bot after updating.
**400 Bad Request on order placement**: Usually a tick-size violation (price not rounded to 0.01) or a below-minimum size. Log the full request payload before submission so you can see exactly what was sent. The SDK does not always surface the specific reason in the exception message.
**No active market found**: The BTC 15M market briefly disappears from the API in the seconds between one market resolving and the next one opening. Add a retry loop that polls every 5 seconds for up to 60 seconds before giving up on the cycle.
Start with the smallest possible position size
On your first live run, set max_trade_pusd to $2.00 and daily_loss_limit_pct to 0.03 (3%). Watch the bot run for a full day before increasing sizes. The goal of the first live day is to confirm the plumbing works, not to make money. Increase sizes only after you have confirmed fills, cancellations, and PnL tracking are all working correctly in production.
You have a working Polymarket trading bot
If health_check.py passes, your paper trades show a reasonable signal, and the bot runs two live cycles without errors, you have built a complete, production-ready Polymarket trading bot. From here, the work is signal improvement, threshold tuning, and monitoring. The infrastructure you built in this tutorial handles everything else.