Polymarket Bot Tutorial · Chapter 29 of 32
Build a Polymarket paper trading engine before going live: simulate orders against real prices, track P&L, enforce the 30-trade gate (>=55% win rate, +PnL) before any live capital, and code skeleton.
What this chapter covers
Paper trading is the non-negotiable step between strategy idea and live deployment. This chapter is the simple paper engine that has gated every live bot we've shipped - under 200 lines of Python, tracks every trade in a JSONL diary, applies the same fees/slippage as the live path.
- Why paper before live (always)
- The 30-trade gate (verified +55% WR + positive PnL)
- Building a simple paper engine
- Tracking paper diary alongside live diary
- When paper diverges from live (and why)
- Graduating to live: small first deposit
- Code: minimal paper engine
Why paper before live (always)
The 30-trade paper gate is the single discipline that separates the 15.9% of profitable Polymarket traders from the 84.1% who lose. Most builders skip it and pay tuition. The honest reason it works: paper trading reveals the strategy's true win rate over enough samples to distinguish signal from luck.
Skipping paper costs more than it saves. A strategy that looks profitable in backtest but is actually a coin flip will burn through $200-500 of live capital before producing a 30-sample size of live data. Paper-trading the same 30 trades costs $0.
The paper engine does not need to be sophisticated. It needs to be honest - same fees, same slippage, same fill latency as the live path. The simpler the better, because anything optional gets cut and the bot ships live earlier than it should.
The 30-trade gate (verified +55% WR + positive PnL)
The gate is binary: 30 closed paper trades, written-in-advance success criteria (typically WR ≥ 55% on a positive-EV strategy), or no live deployment.
30 is the minimum sample size where the 95% confidence interval on the true win rate is narrow enough to distinguish signal from noise. Below 30, a 60% observed rate could correspond to a true rate of 45-75%. At 30+, the interval narrows to ~50-70% - still wide, but enough to rule out "the strategy is a coin flip."
The success criteria must be set BEFORE the paper run starts. Setting them after produces post-hoc rationalization (you'll find a way to interpret any 30 trades as "good enough").
Building a simple paper engine
The paper engine is essentially the live trading code with the order-placement function swapped for a simulated fill. The simulation:
- Read live order book: same call as the live bot would make.
- Simulate fill: if buying at FOK with price ≥ best ask, fill the order at the volume-weighted average of asks consumed; record the fill in the paper diary.
- Apply fees: subtract the same fees the live path would pay.
- Track inventory: maintain a parallel paper-balance and paper-positions dictionary.
The whole engine fits in 100-200 lines of Python. The key discipline: every assumption the live path makes (fill rate, latency, fee) must be reproduced in paper, even if slightly worse than reality - paper should be the floor, not the ceiling.
Tracking paper diary alongside live diary
The paper trading run produces a JSONL diary indistinguishable in structure from the live diary the bot will write later. Same fields: timestamp, action, market_slug, side, size, price, expected_fill_price, simulated_pnl_at_exit.
Two reasons to use the same format. First, the analysis tools that read live trades (PnL reports, win-rate calculators) work on paper without modification. Second, comparing paper to live later catches divergences that indicate bugs.
Production tip: have the paper engine write to per_trade_paper.jsonl in the same directory as the live per_trade.jsonl. Single command compares both: diff -y <(jq -r .market_slug per_trade.jsonl) <(jq -r .market_slug per_trade_paper.jsonl).
When paper diverges from live (and why)
Inevitable divergences between paper and live. Three common ones.
- Slippage: paper fills at the ask snapshot; live walks the book and may fill 1-2c worse on thin markets. Solution: simulate slippage in paper by adding a per-trade penalty equal to half the spread.
- Fill latency: paper fills instantly; live takes 200-500ms during which the price may move. Solution: simulate by waiting and re-reading the book before "filling" in paper.
- Adverse selection: paper assumes you get the best ask; live competes with other bots who may have already lifted that ask. Solution: harder to simulate; honest disclosure to yourself that paper overestimates.
When paper says +5%/month and live runs at -2%/month, the gap is usually one of these. Audit them one by one rather than assuming the strategy itself was wrong.
Graduating to live: small first deposit
Paper passes 30 trades. Live deployment plan:
- Deposit $25-50 as smoke-test capital. Treat as tuition; if you lose it, the lesson was worth it.
- Run the bot in live mode for 5-10 trades with positions at minimum size (5 shares).
- Verify each fill matches paper expectations within 2c. Investigate any larger gap before continuing.
- If 5-10 live trades match paper, deposit $200-500 and run normal-size positions.
- If they don't match, halt, debug, fix, restart from step 1.
The most common live-paper gap on first deployment is a missing fee or a slippage misestimate. Fixing those is straightforward; the discipline is catching the gap before scaling capital.
Code: minimal paper engine
Reference: simple paper engine that reads live book + simulates FOK fill.
import json, time
PAPER_BAL = 10_000.0 # USD starting
positions = {} # token_id -> shares
def paper_fok_buy(token_id, max_price, size):
book = fetch_book(token_id)
# Walk asks, fill what we can within max_price
filled = 0; cost = 0
for level in book.asks:
px = float(level["price"])
if px > max_price: break
avail = float(level["size"])
take = min(avail, size - filled)
filled += take
cost += take * px
if filled >= size: break
if filled < size:
return {"status":"rejected","filled":0} # FOK semantics
global PAPER_BAL
PAPER_BAL -= cost
positions[token_id] = positions.get(token_id, 0) + filled
log_paper({"ts": int(time.time()), "action":"buy",
"token": token_id, "size": filled, "price": cost/filled})
return {"status":"matched","filled":filled,"cost":cost}
Production additions: paper sell function (mirror of buy), paper GTC simulation (post on book at price, simulate fill when mid reaches price), reconciliation between paper diary and "would-have-been" live diary.





