Welcome to API Academy
Module 16 · Production · ~25 min
Arbitrage.
By the end of this module, your bot can spot prices that don’t add up, and close them before someone else does. Real money for noticing what the rest of the market hasn’t yet.
To get there, you’ll automate detection and execution of parity mispricings, the canonical “risk-free” trade, once you accept that it isn’t.
Production tier · Reference cardHow does an arbitrage bot work on a prediction market?
It watches for prices that violate an identity, the cleanest being intra-market parity: on a binary market YES + NO must sum to $1, so when bid(YES) + bid(NO) exceeds $1 you sell both sides, and when ask(YES) + ask(NO) is under $1 you buy both. The bot streams orderbookUpdate events from the WebSocketClient across a watch-list, recomputes both sums on every book delta, and only acts when the gap clears fees on both legs plus a buffer (the threshold is 1 + 2×FEE + BUFFER, with a 20 bps fee per leg and a 30 bps buffer in the example). Execution sends both legs as OrderType.FOK orders with unique clientOrderIds, so each leg fills completely or not at all, then confirms via POST /orders/status/batch. It’s the canonical “risk-free” trade, once you accept that it isn’t.
Endpoints verified 2026-06-09 against the OpenAPI spec.
Section 01
Opportunity classes.
Arbitrage on a prediction market has a handful of distinct flavours. Each class has its own detection logic, execution footprint, and failure modes.
Intra-market parity
On a binary market, YES + NO must sum to $1. When bid(YES) + bid(NO) > $1, sell both. When ask(YES) + ask(NO) < $1, buy both. Cleanest possible arb, two legs on one platform.
Cross-platform
The same event listed on two venues at different prices. Buy on the cheaper side, sell on the richer. Carry risk while you bridge capital or wait for resolution.
Correlated markets
Logically related markets whose implied probabilities contradict each other (for example, a “wins election” market and a set of individual state markets that must sum consistently). Not a risk-free arb, a statistical one.
Section 02
Detection loop.
A streaming detection loop over the orderbookUpdate event from WebSocketClient. For each book delta we recompute bid(YES) + bid(NO) and flag anything that exceeds $1 plus our fee + buffer threshold. Resubscribe on reconnect, the server doesn’t persist subscriptions.
How to run this
- Set LIMITLESS_API_KEY, this is a read-only stream, no private key required. Point WATCHED at the slugs you actually want to monitor.
- Save the snippet above as cross-market-arb.ts, then run npx tsx cross-market-arb.ts.
- Save the snippet above as cross_market_arb.py, then run python cross_market_arb.py.
- Save the snippet above as main.go inside a Go module, then run go run main.go.
- The process sits silent most of the time, parity normally holds. When a book prints a SELL-BOTH arb or BUY-BOTH arb line with a slug and a number, you’ve caught a live mispricing outside your fee + 30 bps buffer.
// Module 16, Intra-market parity detection over a websocket stream.
import { WebSocketClient } from '@limitless-exchange/sdk';
const FEE = 0.002; // 20 bps per fill, per leg
const BUFFER = 0.003; // require 30 bps edge after fees
const THRESH = 1 + 2 * FEE + BUFFER;
const WATCHED = ['btc-100k-weekly', 'eth-5k-weekly'];
const ws = new WebSocketClient({
url: 'wss://ws.limitless.exchange',
apiKey: process.env.LIMITLESS_API_KEY,
autoReconnect: true,
});
ws.connect();
// Subscriptions are *replaced* on each call, pass every slug in one go.
const resubscribe = () =>
ws.subscribe('subscribe_market_prices', { marketSlugs: WATCHED });
ws.on('reconnect', resubscribe);
resubscribe();
ws.on('orderbookUpdate', (update) => {
const book = update.orderbook;
const yesBid = Number(book?.yes?.bids?.[0]?.price ?? 0);
const noBid = Number(book?.no?.bids?.[0]?.price ?? 0);
const yesAsk = Number(book?.yes?.asks?.[0]?.price ?? 0);
const noAsk = Number(book?.no?.asks?.[0]?.price ?? 0);
const sellBoth = yesBid + noBid; // sell YES + sell NO ⇒ receive
const buyBoth = yesAsk + noAsk; // buy YES + buy NO ⇒ pay
if (sellBoth > THRESH) {
console.log('SELL-BOTH arb:', update.marketSlug, sellBoth.toFixed(4));
// TODO: hand off to execute()
}
if (buyBoth < 2 - THRESH) {
console.log('BUY-BOTH arb: ', update.marketSlug, buyBoth.toFixed(4));
// TODO: hand off to execute()
}
});
# Module 16, Intra-market parity detection over a websocket stream.
import asyncio
from limitless_sdk.websocket import WebSocketClient, WebSocketConfig
FEE = 0.002 # 20 bps per fill, per leg
BUFFER = 0.003 # require 30 bps edge after fees
THRESH = 1 + 2 * FEE + BUFFER
WATCHED = ["btc-100k-weekly", "eth-5k-weekly"]
config = WebSocketConfig(
url="wss://ws.limitless.exchange",
auto_reconnect=True,
reconnect_delay=5,
)
ws_client = WebSocketClient(config)
@ws_client.on("connect")
async def on_connect() -> None:
# Subscriptions are *replaced* on each call, send every slug in one go.
await ws_client.subscribe(
"subscribe_market_prices",
{"marketSlugs": WATCHED},
)
@ws_client.on("orderbookUpdate")
async def on_book(update: dict) -> None:
book = update.get("orderbook") or {}
yes_bid = float(((book.get("yes") or {}).get("bids") or [{}])[0].get("price", 0))
no_bid = float(((book.get("no") or {}).get("bids") or [{}])[0].get("price", 0))
yes_ask = float(((book.get("yes") or {}).get("asks") or [{}])[0].get("price", 0))
no_ask = float(((book.get("no") or {}).get("asks") or [{}])[0].get("price", 0))
sell_both = yes_bid + no_bid # sell YES + sell NO ⇒ receive
buy_both = yes_ask + no_ask # buy YES + buy NO ⇒ pay
slug = update.get("marketSlug")
if sell_both > THRESH:
print(f"SELL-BOTH arb: {slug} {sell_both:.4f}")
# TODO: hand off to execute()
if buy_both < 2 - THRESH:
print(f"BUY-BOTH arb: {slug} {buy_both:.4f}")
# TODO: hand off to execute()
async def main() -> None:
await ws_client.connect()
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(main())
// Module 16, Intra-market parity detection over a websocket stream.
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
limitless "github.com/limitless-labs-group/limitless-exchange-go-sdk/limitless"
)
const (
Fee = 0.002 // 20 bps per fill, per leg
Buffer = 0.003 // require 30 bps edge after fees
)
var Thresh = 1 + 2*Fee + Buffer
func parse(s string) float64 { v, _ := strconv.ParseFloat(s, 64); return v }
func main() {
ctx := context.Background()
watched := []string{"btc-100k-weekly", "eth-5k-weekly"}
ws := limitless.NewWebSocketClient(
limitless.WithWebSocketAPIKey(os.Getenv("LIMITLESS_API_KEY")),
)
defer ws.Disconnect()
ws.OnOrderbookUpdate(func(u limitless.OrderbookUpdate) {
yesBid := parse(u.Orderbook.Yes.Bids[0].Price)
noBid := parse(u.Orderbook.No.Bids[0].Price)
yesAsk := parse(u.Orderbook.Yes.Asks[0].Price)
noAsk := parse(u.Orderbook.No.Asks[0].Price)
sellBoth := yesBid + noBid // sell YES + sell NO
buyBoth := yesAsk + noAsk // buy YES + buy NO
if sellBoth > Thresh {
fmt.Printf("SELL-BOTH arb: %s %.4f\n", u.MarketSlug, sellBoth)
// TODO: hand off to execute()
}
if buyBoth < 2-Thresh {
fmt.Printf("BUY-BOTH arb: %s %.4f\n", u.MarketSlug, buyBoth)
// TODO: hand off to execute()
}
})
if err := ws.Connect(ctx); err != nil {
log.Fatal(err)
}
// Subscribing to ChannelOrderbook *replaces* any prior subscription.
if err := ws.Subscribe(ctx, limitless.ChannelOrderbook, limitless.SubscriptionOptions{
MarketSlugs: watched,
}); err != nil {
log.Fatal(err)
}
select {}
}
Section 03
Execution.
A two-leg arb has to execute atomically, or not at all. Submit both legs as OrderType.FOK with unique clientOrderIds for idempotency, duplicates return 409 Conflict. FOK fills the full makerAmount or rejects, so there’s no half-filled state to roll back. Confirm both legs with POST /orders/status/batch.
// Module 16, Two-leg FOK execution with idempotent client order ids.
import {
HttpClient,
MarketFetcher,
OrderClient,
Side,
OrderType,
ApiError,
} from '@limitless-exchange/sdk';
import { randomUUID } from 'node:crypto';
async function executeSellBoth(
httpClient: HttpClient,
orders: OrderClient,
marketFetcher: MarketFetcher,
slug: string,
makerAmount: number,
) {
const market = await marketFetcher.getMarket(slug);
const yesId = market.positionIds[0];
const noId = market.positionIds[1];
const keyBase = randomUUID();
const legAId = `${keyBase}-yes`;
const legBId = `${keyBase}-no`;
const submitLeg = (tokenId: string, clientOrderId: string) =>
orders.createOrder({
marketSlug: slug,
tokenId,
side: Side.SELL, // sell both sides for > $1 parity
makerAmount,
orderType: OrderType.FOK,
clientOrderId,
}).catch((err) => {
// 409 = duplicate clientOrderId; safe to treat as already-submitted.
if (err instanceof ApiError && err.status === 409) return null;
throw err;
});
const [legA, legB] = await Promise.allSettled([
submitLeg(yesId, legAId),
submitLeg(noId, legBId),
]);
// Confirm settlement via POST /orders/status/batch (1–50 items).
const statusResp = await httpClient.post('/orders/status/batch', {
items: [{ clientOrderId: legAId }, { clientOrderId: legBId }],
});
for (const r of statusResp.results) {
console.log(r.clientOrderId, '→', r.data?.execution?.settlementStatus);
}
if (legA.status === 'rejected' || legB.status === 'rejected') {
console.warn('at least one leg rejected', legA, legB);
}
}
# Module 16, Two-leg FOK execution with idempotent client order ids.
import asyncio
import uuid
from limitless_sdk.api import HttpClient, APIError
from limitless_sdk.markets import MarketFetcher
from limitless_sdk.orders import OrderClient
from limitless_sdk.types import Side, OrderType
async def execute_sell_both(
http_client: HttpClient,
orders: OrderClient,
market_fetcher: MarketFetcher,
slug: str,
maker_amount: float,
) -> None:
market = await market_fetcher.get_market(slug)
yes_id = market["positionIds"][0]
no_id = market["positionIds"][1]
key_base = str(uuid.uuid4())
leg_a_id = f"{key_base}-yes"
leg_b_id = f"{key_base}-no"
async def submit(token_id: str, client_order_id: str):
try:
return await orders.create_order(
token_id=token_id,
maker_amount=maker_amount,
side=Side.SELL, # sell both sides for > $1 parity
order_type=OrderType.FOK,
market_slug=slug,
client_order_id=client_order_id,
)
except APIError as e:
if e.status_code == 409: # duplicate clientOrderId
return None
raise
leg_a, leg_b = await asyncio.gather(
submit(yes_id, leg_a_id),
submit(no_id, leg_b_id),
return_exceptions=True,
)
# Confirm settlement via POST /orders/status/batch (1–50 items).
status = await http_client.post(
"/orders/status/batch",
json={"items": [
{"clientOrderId": leg_a_id},
{"clientOrderId": leg_b_id},
]},
)
for r in status.get("results", []):
settlement = (r.get("data") or {}).get("execution", {}).get("settlementStatus")
print(r.get("clientOrderId"), "→", settlement)
if isinstance(leg_a, Exception) or isinstance(leg_b, Exception):
print("at least one leg rejected:", leg_a, leg_b)
// Module 16, Two-leg FOK execution with idempotent client order ids.
package main
import (
"context"
"errors"
"fmt"
"log"
"github.com/google/uuid"
limitless "github.com/limitless-labs-group/limitless-exchange-go-sdk/limitless"
)
func executeSellBoth(
ctx context.Context,
client *limitless.HttpClient,
orders *limitless.OrderClient,
marketFetcher *limitless.MarketFetcher,
slug string,
makerAmount float64,
) error {
market, err := marketFetcher.GetMarket(ctx, slug)
if err != nil {
return err
}
keyBase := uuid.New().String()
legAID := keyBase + "-yes"
legBID := keyBase + "-no"
submit := func(tokenID, clientOrderID string) error {
_, err := orders.CreateOrder(ctx, limitless.CreateOrderParams{
OrderType: limitless.OrderTypeFOK,
MarketSlug: slug,
ClientOrderID: clientOrderID,
Args: limitless.FOKOrderArgs{
TokenID: tokenID,
Side: limitless.SideSell, // sell both sides for > $1 parity
MakerAmount: makerAmount,
},
})
// 409 = duplicate clientOrderId; safe to treat as already-submitted.
var apiErr *limitless.APIError
if errors.As(err, &apiErr) && apiErr.Status == 409 {
return nil
}
return err
}
errA := submit(market.Tokens.Yes, legAID)
errB := submit(market.Tokens.No, legBID)
// Confirm settlement via POST /orders/status/batch (1–50 items).
body := map[string]any{
"items": []map[string]string{
{"clientOrderId": legAID},
{"clientOrderId": legBID},
},
}
resp, _ := client.Post(ctx, "/orders/status/batch", body)
fmt.Println("status batch:", resp)
if errA != nil || errB != nil {
log.Println("at least one leg rejected:", errA, errB)
}
return nil
}
How to run this
- Set LIMITLESS_API_KEY and PRIVATE_KEY, this function fires two FOK legs. Wire it into the detection loop from Section 02 so it only runs when sellBoth > THRESH.
- Drop executeSellBoth into cross-market-arb.ts and call it from your orderbookUpdate handler.
- Drop execute_sell_both into cross_market_arb.py and call it from your on_book handler.
- Drop executeSellBoth into your main.go and call it from the OnOrderbookUpdate callback.
- On a live arb, both legs fire in parallel, the /orders/status/batch call prints a settlementStatus for each clientOrderId, and a replay of the same UUID returns 409 Conflict, proof your idempotency key works.
Section 04
When arb isn’t risk-free.
Four risks every arb bot accepts
“Risk-free arbitrage” is a phrase people use right up until they learn otherwise. Model each of these explicitly before you turn size up.
Execution risk
FOK makes each leg atomic, but 429 rate limits and transient 5xxs eat the retry budget, by the time the second leg lands, the edge is gone. Wrap both submits in withRetry / retry_on_errors / WithRetry, and keep retry counts low.
Settlement risk
Two venues resolve the same event differently. Your “matched” arb ends up with one winning leg and one losing leg by decree.
Capital lockup
Collateral is tied up until resolution. Your 1% guaranteed edge over 90 days is a 4% APR, and you can’t deploy that capital anywhere else meanwhile.
Tax treatment
A matched book is one bet to the exchange. It is two bets to the tax authority. The winning leg is income, the losing leg may or may not be deductible. Talk to an accountant.
Prediction market arbitrage: what people ask
Each answer also ships invisibly as schema.org FAQ data for search engines and AI assistants. Tap a question to expand.
-
What is YES + NO parity arbitrage?
On a binary market the YES and NO contracts must sum to $1. When the best bids sum above $1, sell both sides and pocket the difference; when the best asks sum below $1, buy both for less than the guaranteed $1 payout. Two legs, one venue, minimal risk, the cleanest arb class on a prediction market. Cross-platform and correlated-market arbs exist too, but they carry bridge, settlement-mismatch, and model-assumption risk respectively. -
How do you detect arbitrage in real time on Limitless?
Subscribe toorderbookUpdateover theWebSocketClient(wss://ws.limitless.exchange) for a watch-list of slugs and recompute bid(YES) + bid(NO) and ask(YES) + ask(NO) on every delta. Flag only gaps that clear1 + 2×FEE + BUFFER: fees for both legs plus a 30 bps safety margin. Two operational details: subscriptions are replaced on each call, so pass every slug in one go, and the server doesn’t persist subscriptions, so resubscribe on every reconnect. -
Why use FOK orders for arbitrage execution?
Fill-or-kill makes each leg atomic: the order fills its fullmakerAmountor rejects outright, so there’s no half-filled state to roll back. Give each leg a uniqueclientOrderId; a duplicate submission returns409 Conflict, which is safe to treat as already-submitted. Confirm both legs afterward withPOST /orders/status/batch(1–50 items) and read back each leg’ssettlementStatus. -
Is prediction market arbitrage actually risk-free?
No. Four risks survive even a perfect detector: execution risk (429s and transient 5xxs delay the second leg until the edge is gone, so wrap submits in the SDK retry helper with low retry counts); settlement risk (two venues can resolve the same event differently, leaving one losing leg by decree); capital lockup (collateral is tied up until resolution, so a 1% edge over 90 days is a 4% APR); and tax treatment (the winning leg is income, the losing leg may not be deductible). -
What do you do when one arbitrage leg doesn’t fill?
Hedge immediately. After each leg fills, check the next leg’s book against your fill price; if the projected edge has gone negative, take the small loss at the new market price instead of holding open exposure. A missed leg silently converts your arb into a directional position you never intended, and hoping the price comes back is a directional bet, not an arb.
Section 05
Module checklist.
Tick each item once you’ve actually done it. The Continue button unlocks at 5/5.
I can name intra-market parity, cross-platform, and correlated-market arbs
My detection threshold includes fees for both legs and a safety buffer
Both arb legs submit as OrderType.FOK with unique clientOrderIds and fire in parallel
I confirm both legs via POST /orders/status/batch and wrap submissions in the SDK retry helper
I accept execution, settlement, lockup, and tax risk, none of my arbs are “risk-free”
Module 16 complete
Free lunches spotted.
Your bot trades the cleanest edge in finance: prices that have to be wrong. When YES + NO doesn’t add to a dollar or sibling markets drift apart, your bot is the one closing the gap and pocketing the difference, in seconds, while you’re doing something else.
Concretely, your bot finds and executes parity arbs without you watching the book. Three things you walk away with:
A streaming detector over WebSocketClient that watches a slug list, computes bid(YES) + bid(NO) and ask(YES) + ask(NO) on every book delta, and only flags edges that clear a fee + 30 bps buffer.
A two-leg OrderType.FOK executor keyed by a shared clientOrderId UUID, plus a POST /orders/status/batch confirmation that reads back settlementStatus for both legs.
An honest mental model of the four risks an arb bot actually carries, execution, settlement, capital lockup, and tax treatment, so you can size with your eyes open.
Next up: trading on signals, not chart shapes, EMA crosses, probability divergences, and the feature/threshold/cooldown pattern every signal bot shares.
Complete the checklist above to unlock