Every five minutes a new Bitcoin market opens with one question: will BTC be higher than it is right now when the five minutes are up? Polymarket and Kalshi both run these, and if you search GitHub for "polymarket 5 minute bot" you'll find plenty of repos. Most of them stop at "connect and place an order". The part that decides whether the bot makes any sense, what the Up side is actually worth at this moment, usually isn't there.
So this post is mostly about that part, plus the plumbing around it.
Disclosure: I work on Lucyd, an exchange that runs the same kind of rounds (5 minutes up to a day, settled on the Pyth BTC/USD price). It's on the Sepolia testnet right now with test USDC, and that's why I'm using it here: you can point the bot at a live order book without putting money in. The model and the backtest idea carry over to any venue.
What a round is
Each round has two tokens, Up and Down. The winning one pays $1, the other pays nothing. If Up trades at 0.62, the market is saying "62% chance BTC finishes above the strike". The strike is the price at the start of the round.
If you've never seen these markets, this short video shows a round from open to payout:
The bot needs its own number for that chance. If the book sells Up noticeably cheaper than the bot thinks it's worth, it buys. That's the whole strategy. Everything else is getting the inputs right and not blowing up.
You'll need Python 3.10+, pip install requests websockets eth-account duckdb, a wallet with some Sepolia test USDC, and a one-time USDC approval to the Exchange contract. Addresses are in the API docs.
Following the rounds
The obvious way is to poll the API for the open round every five minutes. You don't have to. The public book stream (no login) lets you subscribe to a whole series: the server sends the open round right away, then each new round the moment it opens (open_markets), and expired when one ends. The bot never has to guess when the next round starts.
The series feed only tells you about rounds. For the book itself, subscribe to each new round by id on the same connection:
import json, websockets
BOOK_WS = "wss://clob.testnet.lucyd.trade/v1/ws/book"
async def follow_series(period, on_round, on_book, on_expired):
async with websockets.connect(BOOK_WS) as ws:
await ws.send(json.dumps({"type": "subscribe_series", "requestId": "series",
"market_type": "UpDown", "period": period, "instrument": "BTC"}))
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "open_markets":
for m in msg["markets"]:
# leftOutcomeId is Up, rightOutcomeId is Down
on_round(m["marketId"], m["leftOutcomeId"], m["rightOutcomeId"])
await ws.send(json.dumps({"type": "subscribe", "requestId": m["marketId"],
"marketIds": [m["marketId"]]}))
elif msg["type"] == "book":
best_bid = max((float(b["price"]) for b in msg["bids"]), default=None)
best_ask = min((float(a["price"]) for a in msg["asks"]), default=None)
on_book(msg["asset_id"], best_bid, best_ask)
elif msg["type"] == "expired":
on_expired(msg["marketIds"])
The stream doesn't carry the strike, so when a new round shows up, fetch it once from the REST API. The market id from the stream is the same as address there, and the strike is an integer with 8 decimals:
import requests
from datetime import datetime
API = "https://api.testnet.lucyd.trade"
def round_info(market_id):
m = requests.get(f"{API}/markets/{market_id}").json()
return {
"strike": m["strike"] / 1e8,
"end": datetime.fromisoformat(m["end_date"].replace("Z", "+00:00")),
}
Series exist for 5m, 15m, 30m, 1h and 4h. Daily rounds aren't in the series feed, so for those take the open market from GET /markets?state=open&period=Daily and subscribe to its book by id.
Prices are strings on a 0.0025 tick. One thing that confuses people at first: Up and Down share liquidity. Buying Down at 0.40 is the same trade as selling Up at 0.60, so don't be surprised when both sides move together.
What Up is worth
Here's the simplest model that's still useful. Over a few minutes, BTC moves roughly like a random walk, so the chance of finishing above the strike depends on two things: how far the price is from the strike right now, and how much time is left.
import math
from statistics import NormalDist
SIGMA_5M = 0.00065 # typical 5-minute log move, measured on settled rounds
def fair_up(price, strike, seconds_left):
if seconds_left <= 0:
return 1.0 if price > strike else 0.0
sigma = SIGMA_5M * math.sqrt(seconds_left / 300)
return NormalDist().cdf(math.log(price / strike) / sigma)
Sanity check: at the open, with the price sitting on the strike, you get 0.50. With 150 seconds left and BTC $40 above an $81,000 strike, you get about 0.86. $40 doesn't sound like much, but with half the round gone it's a big lead.
The model is wrong in all the usual ways. Volatility isn't constant (it jumps around the US open and on news), and the tails are fatter than a normal distribution. A better version measures sigma from the last hour instead of using a constant. If you want to see where 0.00065 comes from, we published statistics on 8,000+ settled rounds, with the raw data.
About the price input: the round settles on Pyth BTC/USD, which aggregates major exchanges. For a live signal, Binance BTC/USDT moves almost in step with it, but not exactly. Log both for a while and look at the gap before you trust Binance near the strike.
When to buy
Buy a side when its best ask is below fair value by more than a margin. Cap the size per round.
MARGIN = 0.04
def decide(fair, up_ask, down_ask):
if up_ask is not None and up_ask < fair - MARGIN:
return ("up", up_ask)
if down_ask is not None and down_ask < (1 - fair) - MARGIN:
return ("down", down_ask)
return None
Four cents is a guess at how wrong the model can be. On venues with taker fees the margin also has to cover the fee, which on short crypto rounds can eat most of a small edge. Lucyd has no trading fees, so here it's purely your error budget.
Placing orders
Orders go over an authenticated WebSocket (wss://clob.testnet.lucyd.trade/v1/ws). You sign a login challenge and then each order with your wallet key (EIP-712). Funds stay in your wallet until a fill. For taking liquidity, use time in force fak (fill what you can now, cancel the rest) with a short expiration. Prices have to be on the 0.0025 tick, sizes are whole tokens:
def amounts(price, tokens):
price = round(price / 0.0025) * 0.0025
return round(price * tokens * 1_000_000), tokens * 1_000_000 # makerAmount (USDC), takerAmount (tokens)
I'm not pasting the full login and signing code here because it's long and boring. A working version is in the Python example in the docs; drop decide() into it.
Things that bit us while running our own market maker on this:
- The session lasts 15 minutes. The server closes the socket, and you log in again. Build the reconnect in from day one.
- Cancel nonces must strictly increase. If you send two cancels in the same millisecond, the second one gets rejected and the order stays in the book. When you quote both sides, this is how you end up trading against yourself. Space them out.
- After a reconnect, read your open orders before placing new ones. Your local idea of what's resting in the book is wrong more often than you'd think.
- Stay out of the last seconds unless your price feed is genuinely fast. Near the strike, a single print decides the round, and whoever sees it first wins.
Backtesting on real books
Before running it live, replay it. The full L2 book of every finished round is published as a Parquet file, free and without a key. Strike and result come from the API:
import duckdb, requests
rounds = requests.get(f"{API}/markets", params={"state": "settled", "period": "5m", "limit": 100}).json()
r = rounds[0]
url = f"https://data.testnet.lucyd.trade/books/updown/5m/btc/{r['start_date'][:10]}/{r['address']}.parquet"
book = duckdb.sql(f"SELECT * FROM read_parquet('{url}') ORDER BY timestamp").df()
print(r["winner"], r["strike"] / 1e8, r["resolved_value"] / 1e8, len(book))
Each row is either a full snapshot or a change at one price level (format). Walk through it, rebuild the best bid and ask at each moment, compute fair_up from the BTC price at that time, and record what the bot would have bought and whether it won.
I'm deliberately not showing a P&L curve. With a model this simple, the result mostly depends on how much faster your price feed is than the people quoting the book. That's the number worth measuring yourself, and a backtest that ignores latency will look much better than reality.
If I kept going
- Estimate sigma from the last hour and by time of day.
- Quote instead of take: rest orders around fair value and let others cross the spread. More work, since you're now managing inventory, but that's where most of the steady money on these markets is.
- Run 15-minute and 1-hour rounds too. Same code, different
period, and much less of a latency race.
If you'd rather not write code at all, the same markets can be traded by an AI agent through an MCP server. I wrote that one up separately: a trading agent with Claude Code.
Mainnet with real USDC is next for us. The API and the order format stay the same, so a bot you test now should only need new URLs and addresses.
Questions and corrections welcome in the comments. If something in the API trips you up, tell me and I'll fix the docs.
Top comments (0)