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 making your first authenticated API call and pulling live data from the BTC Up/Down 15M market. This step covers how to initialize the CLOB client, how to dynamically look up the currently active market ID for BTC 15M, and how to parse the order book data the API returns. By the end, your bot will be able to read the current UP and DOWN prices in real time.
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 making your first authenticated API call and pulling live data from the BTC Up/Down 15M market. This step covers how to initialize the CLOB client, how to dynamically look up the currently active market ID for BTC 15M, and how to parse the order book data the API returns. By the end, your bot will be able to read the current UP and DOWN prices in real time.
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: Fetch and Parse the Order Book
Use this after the market lookup code is working. This prompt asks the agent to add order book fetching so your bot can read the current best bid and ask prices for UP and DOWN shares.
I have a working Polymarket CLOB client (ClobClient from py-clob-client-v2) and a function
that returns the active BTC Up/Down 15M market as a dict with keys:
condition_id, question, end_date_iso, up_token_id, down_token_id
Please add a function called get_order_book_summary(client, token_id) to utils.py that:
1. Calls client.get_order_book(token_id) to fetch the order book for a given token
2. Extracts the best bid (highest buy price) and best ask (lowest sell price)
3. Calculates the mid price as (best_bid + best_ask) / 2
4. Returns a dict: { 'best_bid': float, 'best_ask': float, 'mid': float, 'spread': float }
5. Handles the case where the order book is empty (returns None)
6. Logs the mid price and spread at DEBUG level
Also add a function called get_market_snapshot(client) that:
1. Calls get_active_btc_15m_market() to get the current market
2. Calls get_order_book_summary for both up_token_id and down_token_id
3. Returns a combined dict with yes and no order book summaries plus the market metadata
4. Raises a RuntimeError if no active market is found
Use the existing logger from utils.py. Add 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 ID
The BTC Up/Down 15M market is not a single permanent market — it is a series of markets, each lasting 15 minutes. Every 15 minutes, the current market resolves and a new one opens. The new market gets a new `condition_id` (the unique identifier Polymarket uses for each market).
Your bot must query the markets endpoint on every cycle to find the currently active market. You filter by the market's slug or question text, then pick the one with `active: true` and the nearest resolution timestamp. Hardcoding a condition ID will work for one cycle and then silently fail for every subsequent one.
The API returns a paginated list of markets. Prefer matching on the canonical `market_slug` (e.g. `btc-up-or-down-15m-...`) over fuzzy question-text matches — question wording changes more often than the slug pattern does. Use the question-text check only as a fallback safety net. The `tokens` array inside each market contains the UP and DOWN token IDs — you need those for order placement in Step 5.
import requests
from typing import Optional
CLOB_HOST = "https://clob.polymarket.com"
def get_active_btc_15m_market() -> Optional[dict]:
"""
Returns the currently active BTC Up/Down 15M market dict,
or None if no active market is found.
"""
url = f"{CLOB_HOST}/markets"
params = {"next_cursor": "", "limit": 100}
while True:
resp = requests.get(url, params=params, timeout=10)
resp.raise_for_status()
data = resp.json()
for market in data.get("data", []):
if not market.get("active"):
continue
# Prefer the canonical market slug. Polymarket's naming for
# the 15-minute BTC market is "btc-up-or-down-15m-<window>".
# Fall back to question-text matching as a safety net.
slug = (market.get("market_slug") or "").lower()
q = (market.get("question") or "").upper()
is_btc_15m = (
slug.startswith("btc-up-or-down-15m")
or slug.startswith("btc-up-down-15m")
or ("BTC" in q and "15 MINUTE" in q and ("UP" in q or "DOWN" in q))
)
if is_btc_15m:
return {
"condition_id": market["condition_id"],
"question": market["question"],
"market_slug": market.get("market_slug"),
"end_date_iso": market["end_date_iso"],
"up_token_id": market["tokens"][0]["token_id"],
"down_token_id": market["tokens"][1]["token_id"],
}
cursor = data.get("next_cursor")
if not cursor:
break
params["next_cursor"] = cursor
return None
Rate limits are strict
Polymarket's CLOB API enforces rate limits per IP. Polling the order book more than once every few seconds will trigger 429 errors. Always use the retry_with_backoff helper from utils.py and cache market data between cycles rather than re-fetching it on every function call.
You are ready for Step 4 when...
Running get_market_snapshot() prints a dict with yes and down mid prices between 0.01 and 0.99, and the two prices sum to approximately 1.0 (minus the spread). If they don't sum near 1.0, you may have the UP and DOWN token IDs swapped.
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 / DOWN-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.