This project does one thing: for each newly starting Polymarket BTC five-minute market, it rests equal post-only buy orders on Up and Down. When either entry fills, it waits for the conditional tokens to settle and then places the configured take-profit ladder for the available matched shares. Any fraction not assigned to the ladder is held through resolution. It runs continuously until stopped.
- BTC Up or Down five-minute markets only.
- Place both GTC orders before the market starts.
- Buy price and quote amount are required
runarguments. --take-profit PRICE:FRACTIONis repeatable. Fractions not assigned to an exit rung are held through resolution. Omitting every rung selects buy-only mode.- Do not chase prices or cancel one side during the market.
- With take-profit rungs,
MATCHEDrecords the fill but does not make the position sellable. The bot waits for aMINED/CONFIRMEDtrade update or an available conditional-token balance before offering each configured fraction. - Cancel any unfilled remainder after the market ends.
- Runtime, open reservation, and daily filled-cost limits are optional CLI inputs.
- An omitted limit is unlimited.
- Cancel remaining entry and exit orders before the market cutoff; the lead time
is controlled by
--cancel-before-end-secondsand defaults to two seconds. Set it to0to leave orders resting until Polymarket closes the market. - Live entries start submitting as soon as CLOB exposes the market's token
ids and keep resubmitting the same pre-signed orders until both are
accepted.
--placement-interval-mssets the tick cadence and defaults to 20 milliseconds. In single-submission mode each tick sends one pending leg, and a leg stops consuming budget once the exchange registers it; a 25 ms tick matches the Standard tier's 40 tokens per second refill. - Live runs append every submission attempt to
logs/attempts.jsonl(attempt number, send and return time, reply kind per leg) so a session can show exactly when the book began accepting orders. The trace stays: it is the record every queue-position analysis is built on. The entry modes, including the solo modes, are permanent trading behaviour. - The bot does not submit redemption transactions. Resolved winnings are returned by Polymarket's account-side settlement service.
This is an experimental tail-event strategy. It has not been proven to have a stable positive expectation. Use a dedicated wallet and small limits.
Entry orders are knocked by a Go library loaded into the bot's process
(knocker/, called through polymarket_bot/knocker.py). Python signs each
account's orders; the library sends them on the fleet's timetable over its
own HTTP/2 connections (15, filled one at a time, 100 requests each), judges
every reply, stops each account once the venue has registered its orders,
collects the replies still in flight for three seconds, and hands every reply
to the attempt trace - also the ones that land later. The request bytes are
the official client's own (order_to_json_v2), and the L2 headers are checked
byte for byte against its create_level_2_headers. knocker/testdata/
holds the Python sender's verdict on each kind of reply, which the library
must reproduce.
The Linux build, polymarket_bot/libknocker.so, is committed together with
its sources and stamped with their hash; a test fails when the two part.
knocker/build.ps1 (Go and zig) runs the Go tests and rebuilds it, along
with a Windows build and a stand-in venue for local tests. The server needs
neither Go nor zig.
Everything else authenticated uses py-clob-client-v2 -> httpx -> httpcore.
httpcore is pinned to commit
35ddb373e13be5940e5137798d5a63d67e10f3e2 from
baizhufbb/httpcore, which contains the proxy TLS zombie-connection fix.
At startup polymarket_bot/transport.py replaces the library's shared
httpx.Client(http2=True) with an HTTP/1.1 pool (64 connections): on the
single multiplexed connection, httpcore serializes socket reads behind a
lock, so one slow reply held back every other one. Each placement warms the
pool before knocking so the cancels right after the door do not pay
per-connection TLS handshakes.
Public discovery continues to use requests. Gamma fills the existing window
once, then the predictable next five-minute slug is queried every second. These
requests include a unique cache buster because Gamma otherwise advertises a
five-minute public cache. Each newly discovered condition is checked against
GET /clob-markets/{condition_id} every 250 milliseconds. As soon as CLOB
returns both real token IDs, the main loop is woken and submits the
post-only pair.
The venue meters orders per signer (about 40/s sustained) and the queue
slot is decided by the millisecond the first accepted request reaches the
engine, so one account on a 25 ms cadence arrives on average 12.5 ms after
the book opens. --fleet-env FILE (repeatable) adds accounts from
KEY=VALUE files (POLYMARKET_PRIVATE_KEY, POLYMARKET_FUNDER_ADDRESS,
POLYMARKET_SIGNATURE_TYPE, optional CLOB credentials). All members run
the same cadence with phases offset by interval/N, each with a distinct
order size (--fleet-size-step, default +0.024 USD per member) so the
public depth stream can tell their orders apart. After the open the fleet
keeps the order that registered first (PlacementResult.registered_ts_ms,
the earliest reply that carried the order id) and cancels the others, so
exposure stays at one order per market. Orders carry an account column;
cancellation and reconciliation route by it. Fleet mode currently supports
buy-only plans; the per-address Cloudflare limit (200
order requests/s sustained) bounds one server to five members at 25 ms.
The scheduled five-minute slot, Gamma metadata creation time, CLOB parameter availability, and order acceptance time are separate. Markets may become ready out of slot order, so each condition is tracked independently.
| Stage | Interface | Code |
|---|---|---|
| Parse command and validate credentials | Local CLI and .env.trading |
polymarket_bot/cli.py, polymarket_bot/config.py |
| Open the run and local state | SQLite data/bot.sqlite |
polymarket_bot/database.py |
| Discover startup metadata | GET https://gamma-api.polymarket.com/events |
polymarket_bot/discovery.py |
| Discover every new market | Cache-busted GET https://gamma-api.polymarket.com/markets/slug/{slug} every second |
polymarket_bot/discovery.py, polymarket_bot/market_activation.py |
| Detect CLOB market parameters | GET https://clob.polymarket.com/clob-markets/{condition_id} every 250 ms |
polymarket_bot/market_activation.py |
| Submit both entry orders | Authenticated CLOB batch post_orders, post-only GTC |
polymarket_bot/exchange.py |
| Receive fills and cancellations | Authenticated CLOB user WebSocket | polymarket_bot/user_stream.py |
| Verify open-order state | CLOB get_open_orders and get_order |
polymarket_bot/reconciliation.py |
| Place configured exits | CLOB conditional-token balance plus GTC sell orders | polymarket_bot/service.py, polymarket_bot/exchange.py |
The live farthest-first path runs in this order:
- Gamma fills the configured far-edge window once. The worker then predicts the exact next BTC five-minute slug and requests that slug every second with a unique cache buster until its condition and real Up/Down token IDs exist.
- Each discovered condition is checked independently through
/clob-markets/{condition_id}every 250 milliseconds. - When CLOB returns both token IDs, only that market leaves the pending set. The service validates eligibility, database uniqueness, tick size, minimum size, and optional limits.
- The exchange signs Up and Down locally and submits both in one authenticated
post-only batch. Signing touches the network for nothing: the client is
handed the market's tick size and the protocol version before it can ask
for them, because the tick size it would fetch is only used to check ours
is not finer and discovery already read it off the listing. That lookup
used to answer
market not foundfor a market announced moments ago, which cost run 14 seventeen of seventy-two markets and later cost a fleet member a whole market on its own. Should signing raise a readiness rejection anyway, it is retried in place for up to 0.5 s and then handed back to the main loop as a retryable placement that re-enters on the next tick, instead of abandoning the market. An explicit engine-readiness rejection (invalid token id,market not found, market not ready, or missing order books) preserves the signed pair and immediately retries from the main loop without another parameter request or an added delay. Ambiguous network failures are reconciled before retrying. If that read is temporarily unavailable, the same signed pair remains pending and is reconciled again on the next attempt. If exactly one side is accepted, it is canceled. - Accepted market and order IDs are committed to SQLite. The user WebSocket and REST reconciliation then maintain fills and terminal states.
- In buy-only mode, matched shares are held through resolution. With
--take-profit, settled matched inventory is offered at the configured exit rungs. Redemption is outside the trading process.
Wallet credentials live in .env.trading. Trading values and optional limits
are supplied on the command line.
Trading values are fixed when the process starts. For example, the following ladder buys 100 shares per filled side at one cent, offers 50 shares at two cents, 10 at ten cents, 10 at thirty cents, and holds the remaining 30 shares:
--buy-price 0.01 --usd-per-side 1 `
--take-profit 0.02:0.50 `
--take-profit 0.10:0.10 `
--take-profit 0.30:0.10Order size is usd-per-side / buy-price. Each take-profit fraction is applied
to the matched entry size, including incremental partial fills.
Omitting --take-profit selects buy-only mode. Matched shares are not offered
for sale; they remain held through resolution.
GTC orders stay on the exchange through a network or process outage by design: the queue slot is the strategy's asset, so Polymarket's disconnect-cancels-orders heartbeat is deliberately not used.
runis a dry-run unless--liveis supplied.- The ordinary dry-run checks discovery and order planning only. It does not
model fills or profitability. Use
paperfor an execution-aware simulation. - Live mode also requires
POLYMARKET_LIVE_ACK=I_UNDERSTAND_REAL_ORDERS. - During activation, the bot submits the same signed Up/Down entries at the
--placement-interval-mscadence until both orders are accepted. Each cadence tick starts its submission immediately. Overlapping requests reuse the original order hashes, so a duplicate response identifies the original order instead of creating another position. If the result remains ambiguous, the bot reconciles exchange orders. A temporary reconciliation outage keeps the market pending instead of permanently dropping it. - In the paired modes, Up and Down are submitted together and a lone
accepted side is immediately canceled. The solo modes
(
--entry-submission solo-up/solo-down) deliberately trade one leg and keep it. - Entry and take-profit orders are ordinary GTC limits, so they do not depend on a minimum GTD lifetime. Entries are post-only. A marketable exit target can execute at the target price or better. Only the matched entry quantity is offered.
- Uncovered fills below the market's minimum order size are accumulated until enough shares are available for a valid exit order.
- Exit submission is also capped by the exchange-reported conditional-token balance, so off-chain matches are never treated as already settled inventory.
- An ambiguous exit submission is reconciled against the exchange. If it cannot be identified exactly, it is recorded as failed and is not blindly repeated.
- Farthest-first live runs record Gamma discovery, CLOB parameter detection, and order-submission timestamps.
- Open orders are synchronized in one batch; only orders missing from that response require an individual terminal-status lookup. Exchange reads run in a background worker so reconciliation cannot delay a new-market placement.
- Ctrl+C cancels all open orders recorded by this bot. Every other shutdown path,
including runtime failures and a completed
--hoursduration, leaves exchange orders open for the next run to reconcile. - Explicitly rejected or partially accepted entry batches are not retried. Engine-not-ready, confirmed-empty ambiguous submissions, and temporary submission/reconciliation outages remain eligible until a complete Up/Down pair is accepted or the market ends.
- The bot checks Polymarket geoblocking before and during live operation.
- A local process lock prevents two bot instances from placing duplicate orders.
- Runtime geoblock checks run outside the main loop. A temporary network failure pauses new placements and retries every five seconds without stopping order tracking; an explicit blocked response still stops the run.
Live and ordinary dry-run state use data/bot.sqlite:
runs: every start and stop, mode, fixed trading parameters, and terminal error.markets: each market considered by the bot and its state.orders: entry and exit order IDs, side, price, size, matched size, and status.events: operational audit log.
There is no market-data warehouse and no historical backtest database.
Paper simulation uses the separate data/paper.sqlite. It records the public
0.01 queue when each market first becomes observable, then settles the
hypothetical orders from public taker trades after the market resolves. The
fill model is FIFO and deliberately does not assume that orders ahead canceled,
so exact-price fills are a conservative lower bound. A trade through the limit
price confirms a full fill. Direct sells of an outcome and complementary buys
of the opposite outcome are both counted. Reported PnL excludes variable maker
rebates and never uses wallet credentials or authenticated order endpoints.
The status report also shows active and peak order reserve. Paper mode assumes
every observed market receives both configured orders even when that requires
more collateral than the account actually holds; use the reserve figures when
judging whether its PnL is practically reproducible.
# Install pinned dependencies.
uv sync --dev
# After setting POLYMARKET_PRIVATE_KEY, show the derived signer address only.
uv run --env-file .env.trading bot.py setup
# After signing in with the robot signer wallet, add its Relayer API key and
# matching owner address from Settings > API Keys, then deploy the signer's default
# Deposit Wallet, set missing trading approvals, and save the derived funder
# address and CLOB credentials. This does not deposit funds or place orders.
uv run --env-file .env.trading bot.py setup --apply
# Check authentication, location, collateral, allowance, and open orders.
uv run --env-file .env.trading bot.py doctor
# Run a continuous dry-run. No credentials or orders are used.
uv run bot.py run --buy-price 0.01 --usd-per-side 1 `
--take-profit 0.02:0.50 `
--take-profit 0.10:0.10 `
--take-profit 0.30:0.10
# Run a continuous buy-only dry-run.
uv run bot.py run --buy-price 0.02 --usd-per-side 1 --lookahead-minutes 40
# Show database state.
uv run bot.py status
# Simulate the current one-cent, $1-per-side hold-to-resolution strategy.
# With lookahead 0, existing markets are skipped and the first settled samples
# arrive only after newly announced markets have finished.
uv run bot.py paper --buy-price 0.01 --usd-per-side 1 `
--lookahead-minutes 0
# Show fill counts, conservative PnL, ROI, and recent paper markets.
uv run bot.py paper-status
# Run live continuously.
uv run --env-file .env.trading bot.py run --live `
--buy-price 0.01 --usd-per-side 1 `
--placement-interval-ms 20 `
--take-profit 0.02:0.50 `
--take-profit 0.10:0.10 `
--take-profit 0.30:0.10
# Select 40 minutes from the currently farthest market and place backward.
uv run --env-file .env.trading bot.py run --live `
--buy-price 0.01 --usd-per-side 1 `
--take-profit 0.02:0.50 `
--take-profit 0.10:0.10 `
--take-profit 0.30:0.10 `
--lookahead-minutes 40 --placement-order farthest-first `
--cancel-before-end-seconds 2
# Skip existing markets and begin with the next newly announced farthest market.
uv run --env-file .env.trading bot.py run --live `
--buy-price 0.01 --usd-per-side 1 `
--lookahead-minutes 0 --placement-order farthest-first `
--cancel-before-end-seconds 0
# Run with explicit optional limits.
uv run --env-file .env.trading bot.py run --live `
--buy-price 0.01 --usd-per-side 1 `
--take-profit 0.02:0.50 `
--take-profit 0.10:0.10 `
--take-profit 0.30:0.10 `
--hours 24 --max-reserved-usd 10 --max-daily-filled-cost 20
# Tests.
uv run pytestThe bot records matched entry and exit sizes but does not redeem resolved positions. A successful exit sale returns pUSD immediately; resolved winnings are handled outside this process. Make sure the wallet has enough available pUSD for the orders you allow the bot to open.