How to build a 5-minute Bitcoin Up or Down trading bot
· Lucyd
Five-minute Bitcoin Up or Down markets are one of the most popular targets for trading bots: a new round every five minutes, one clear question, a known maximum loss. This guide builds a small bot in Python on Lucyd step by step, from finding the current round to placing signed orders and testing the strategy on real order book history.
Lucyd suits bots for three reasons: there are no trading fees for takers or makers, so a small edge is not eaten by costs (why that matters); the API, order book stream and full order book history are free and open; and it runs on the Sepolia testnet with test USDC, so you can run the bot live without risking money.
What the bot does
- Follow the 5-minute series: get each new round the moment it opens.
- Stream the order book of that round and fetch its strike.
- Watch the live Bitcoin price and estimate the chance that Up wins.
- Buy Up or Down when the book offers it well below that estimate.
- Repeat every round, within limits.
You need Python 3.10+, pip install requests websockets eth-account duckdb, a wallet with Sepolia test USDC, and a one-time approval of USDC to the Exchange contract. The API docs list the contract addresses and the approvals.
Step 1. Follow the rounds
You could poll the API for the open round every five minutes, but there is no need. The public order book stream, which needs no login, lets you subscribe to a whole series: the server sends the open round right away, then open_markets each time a new round opens, and expired when one ends. The bot never has to guess when the next round starts.
The series feed only announces rounds. To get the book of a round, subscribe to it by id on the same connection. The server then sends a full book snapshot of each outcome and updates; prices are strings on a 0.0025 tick:
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"])
Series exist for 5m, 15m, 30m, 1h and 4h. Daily rounds are not in the series feed: take the open one from GET /markets?state=open&period=Daily and subscribe to its book by id.
Step 2. Get the strike
The stream does not 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 a fixed-point number 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")),
}
One book serves both outcomes: buying Down at 0.40 is the same as selling Up at 0.60.
Step 3. Price Up with a simple model
The round settles on the Pyth BTC/USD price, which aggregates the major exchanges. For a live signal, a spot price stream such as Binance BTC/USDT moves almost in step; calibrate the small difference between the two before trusting it near the strike.
Over a short window, the Bitcoin price behaves close to a random walk. The chance that Up wins depends on how far the price is from the strike and how much time is left:
import math
from statistics import NormalDist
SIGMA_5M = 0.00065 # typical 5-minute move, from 5,000+ 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)
At the open, with the price at the strike, fair_up gives 0.50. With 150 seconds left and Bitcoin $40 above an $81,000 strike, it gives about 0.86. The value of SIGMA_5M comes from our statistics of 8,000 rounds; a better bot measures it from the last hour of prices, because volatility changes through the day.
Step 4. Decide and place an order
The rule: buy a side when the best ask is below its fair value by more than a margin, and never hold more than a set 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
Orders go over the authenticated WebSocket wss://clob.testnet.lucyd.trade/v1/ws. The bot signs a login challenge and each order with its wallet key (EIP-712); funds stay in the wallet until an order fills. For a bot that takes the price on offer, use time in force fak (fill what you can now, cancel the rest) with a short expiration. Prices must sit on the 0.0025 tick, and 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)
The complete, working code for login, order signing, submit and cancel is the Python example in the API docs: plug decide() into it. The session lasts 15 minutes, so reconnect and log in again when the server closes the socket.
Step 5. Backtest on real order books
Before the bot trades, replay it on history. Lucyd publishes the full L2 order book of every finished round as a Parquet file, free and without a key. Combine it with the round's strike and result 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 a full snapshot or a change of one price level; the data page explains the format. For each moment, rebuild the best bid and ask, compute fair_up from the price at that time, and record what the bot would have bought and whether that side won.
Risk limits
- Use a separate wallet with only the funds the bot may trade.
- Cap the size per round and per day, and stop after a set loss.
- Stay out of the last seconds unless your price feed is fast: near the strike a single print flips the result.
- Handle disconnects: the trading session lasts 15 minutes, so build the reconnect in from day one, and after a reconnect read your open orders before placing new ones.
- Keep cancel nonces increasing: each cancel needs a nonce larger than the last. If you use the current millisecond, two cancels in the same millisecond collide and the second is rejected, leaving the order in the book.
Prefer prompts to code?
The same markets can be traded by an AI agent in plain language through the Lucyd MCP server: build a trading agent with Claude.
FAQ
Can I run a bot on 5-minute Bitcoin markets? Yes. The API and order book stream are public, orders are signed with your own key, and there are no trading fees to beat.
Which price decides the round? The first Pyth BTC/USD price published at the end time, compared with the Pyth price at the start. How settlement works.
Where can I get historical order book data? On the data page: every round's full L2 book as Parquet, free.
Do I need real money? Not now: Lucyd runs on the Sepolia testnet with test USDC.