- Python 86.6%
- TypeScript 11%
- JavaScript 1.1%
- Dockerfile 0.5%
- Makefile 0.5%
- Other 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The last scheduled run's log flagged both as unfixed HIGHs blocking
deploy; they were in fact already fixed in
|
||
| .claude | ||
| .github/workflows | ||
| docker | ||
| docs | ||
| migrations | ||
| ops | ||
| research | ||
| secrets | ||
| src/pmbot | ||
| tests | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| alembic.ini | ||
| compose.override.yaml | ||
| compose.yaml | ||
| Makefile | ||
| pyproject.toml | ||
| README.md | ||
| smoke2.log | ||
| uv.lock | ||
pmbot — Polymarket trading bot
Automated trading on Polymarket prediction markets, with a live observability dashboard. Runs as a docker-compose stack.
Paper mode is the default. The bot simulates fills against real order books and touches
no money until you deliberately switch it to live, which requires a wallet key, an
acknowledgement file, and a one-time on-chain onboarding step. See Going live.
Architecture and rationale: docs/DESIGN.md.
Quick start
make dev # or `make up` for the production stack
That creates .env and ./secrets/* if missing, builds both images, runs migrations, and
starts everything. The dashboard is at http://127.0.0.1:8080.
make dev also exposes Postgres (5432), Redis (6379) and the API (8000) on loopback for
debugging. make up exposes only the dashboard.
Before anything real, edit .env and change POSTGRES_PASSWORD.
Stack
| Service | Role |
|---|---|
bot |
The trading process: market data, strategies, risk gate, order manager, reconciler. No listening ports. |
api |
FastAPI: snapshots, history, WebSocket fanout, control plane. Never sees the wallet key. |
web |
The dashboard (nginx + built SPA; Vite dev server under make dev). |
postgres |
Source of truth — orders, fills, positions, PnL, decisions, events. |
redis |
Live bus, heartbeat, kill-switch fast path. |
migrate |
One-shot: alembic upgrade head + partition maintenance. Gates bot and api. |
Making it trade
The bot only trades markets on the watchlist, which starts empty. Nothing happens until you add one.
In the dashboard: search a market in the Watchlist panel and hit + all (or pick a single outcome). The bot resubscribes its market-data socket within a second or two and the strategy starts quoting.
The same thing from the shell:
TOKEN=$(cat secrets/control_token)
# find a market
curl -s 'http://localhost:8080/api/markets?q=bitcoin' | jq '.items[0]'
# watch it — the server resolves the market's outcome tokens itself
curl -s -X POST http://localhost:8080/api/watchlist \
-H "X-PMBOT-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"condition_id":"<condition_id>"}'
# or just one side of it…
curl -s -X POST http://localhost:8080/api/watchlist \
-H "X-PMBOT-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"condition_id":"<condition_id>","outcome":"Yes"}'
# …and raw token ids still work if you have one
curl -s -X POST http://localhost:8080/api/watchlist \
-H "X-PMBOT-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"token_id":"<token_id>"}'
Every mutating request needs the X-PMBOT-Token header (§8). The dashboard fetches the token
itself; for curl read it from secrets/control_token.
Auto-pick: let the bot choose the markets
A selector rule is a filter over the synced markets — minimum 24h volume, minimum
liquidity, maximum spread, how far from resolution — plus a cap on how many markets it may
hold. Whatever passes gets watched automatically, as source='auto' rows the rule alone may
retire; your manual rows are never touched.
Build one in the Auto-pick panel under the watchlist. Pick a strategy (required — a rule
without one can only remove rows, never add), leave every threshold you do not care about
blank, then hit Preview: the panel lists exactly which markets the rule would take right
now, and why the others fall out (watched elsewhere, over cap). Nothing is written until
the preview looks right and you enable the rule.
Three switches gate a real write, and all three start in the safe position:
| Switch | Default | Meaning |
|---|---|---|
PMBOT_SELECTOR__ENABLED |
false |
the selector loop does not run at all |
PMBOT_SELECTOR__DRY_RUN |
true |
it evaluates and logs, but writes no row |
the rule's own enabled |
false |
that one rule proposes nothing |
The panel says which of them is currently stopping the rules. Auto rows are marked with an
auto pill in the watchlist: deleting one by hand only lasts until the next cycle, so either
disable the rule or add the same token again — an add over an auto row claims it back as a
manual row, which no rule will touch again.
max_markets caps what a rule holds after a cycle, not what it wants: a market waiting out
min_dwell_minutes, or one the flatness guard is holding, keeps its slot until it is really
gone. And only markets a discovery sweep saw listed within PMBOT_SELECTOR__MARKET_MAX_AGE_S
(default 2h) can be picked — a market that drops out of Gamma keeps its last volume forever and
would otherwise rank first on every cycle to come.
The starter strategy
mm_midprice is a symmetric post-only market maker. It quotes both sides around the mid,
skews with inventory, and is long-only — it never offers more than it holds, enforced
independently by the risk gate. Makers pay no fees on Polymarket, which is why the starter is
a maker.
It deliberately does nothing when conditions are poor: spread 1t < 3t min in the decision
log means the book was too tight to quote profitably. Every tick is recorded, including the
ones where it chose to sit still — that is what the decision log is for.
Tune it in .env (PMBOT_STRAT_MM__*) or live in the dashboard's Settings drawer, which
writes to the database and wins over the environment.
Reading the dashboard
- Header — mode badge (paper/live/dry_run), equity, day PnL, connection states, and the
kill switch. A red
HB 12S AGOmeans the bot has stopped publishing; past 30s the whole surface greys out, because numbers that old are not decision-grade. - Positions / Open orders / Risk — what you hold, what is resting, and how close each limit is to being hit.
- Equity / Decision log / Fills — the equity curve with a drawdown sub-panel, every strategy decision with its rationale, and the fill tape.
- Watchlist / Auto-pick / Bot health / Event log — what is subscribed, the rules that subscribe markets for you (with a dry-run preview), the bot's internals (heartbeat, reconnects, rate-limit headroom, onboarding), and the raw event stream.
Each panel is crash-isolated: a bad payload costs one tile, not the page.
Stopping it
The kill switch is cumulative and lives in the header. Levels:
| Level | Effect |
|---|---|
| 1 · HALT | No new orders. Resting orders stay. |
| 2 · CANCEL ALL | Cancels everything resting, retried while the level holds. |
| 3 · FLATTEN | Also sells out of every position with reduce-only FAK orders. |
From the shell:
curl -s -X POST http://localhost:8080/api/control/kill \
-H "X-PMBOT-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"level":2,"reason":"why you did this","actor":"you"}'
curl -s -X POST http://localhost:8080/api/control/reset \
-H "X-PMBOT-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"reason":"why it is safe again","actor":"you"}'
A reason is mandatory on both and is audited. The level is written to Postgres first and Redis second, so it survives a bot restart — the bot takes the maximum of both sources on boot and can never be talked down by a stale value.
Escalation also happens without you: the API watchdog raises the level if the bot's heartbeat goes stale, and in live mode a chained heartbeat to the exchange auto-cancels every resting order roughly ten seconds after the bot stops answering.
make down stops the stack and keeps the data. make nuke deletes the volumes and asks
first.
Day-to-day
make ps # service status
make logs # follow bot + api
make psql # SQL shell
make redis-cli # authenticated redis-cli
make test # unit tests (no containers)
make test-all # + integration (spins up Postgres 18 and Redis 8)
make check # everything CI runs: lint, mypy strict, tests
make help # the rest
Editing the dashboard under make dev does not hot-reload by itself. The dev override
uses compose develop.watch, which only syncs while docker compose watch is running in
another terminal. Otherwise the container keeps its build-time copy of web/src and your
edits appear to do nothing — rebuild with docker compose up -d --build web.
Going live
Live mode moves real money. Do a multi-day paper soak first, and read docs/DESIGN.md §3.9
and §0.1 before starting.
- Put your wallet key in
secrets/private_keyand setPMBOT_FUNDER_ADDRESSin.envto the matching address. The bot asserts these agree and refuses to trade if they do not. - Acknowledge deliberately:
echo "acked $(date -u +%FT%TZ)" > secrets/live_ack. An empty file does not count. - Run
make onboard. Polymarket's V2 collateral is pUSD, not USDC — API-only traders have to wrap it and set allowances themselves, because the web UI does this only for browser users. Skipping this means your first live order is rejected or silently unfillable. - Set
PMBOT_MODE=liveand lower the risk limits to something you would not mind losing. make up. Startup refuses to proceed if the key, the acknowledgement, the funder match, or the onboarding checks fail.
Security
- Secrets are compose file-secrets under
./secrets/(directory mode0700), never environment variables and never in.env. Both are gitignored. - The
apicontainer has no exchange access and never loads the wallet key. - The dashboard binds to
127.0.0.1by default. Only widenDASH_BINDbehind an authenticating reverse proxy, and widenDASH_EXTRA_ORIGIN_REGEXwith it. - Logs are redacted; keys and passphrases never reach stdout or the event table.
Known gaps
- Exchange-side position tie-out is unwired — the adapter protocol has no positions endpoint. The nightly ledger rebuild still catches drift.
- Market discovery stops at a 500-page cap (~50k markets) and logs
gamma.page_cap_reachedat ERROR each sweep. Harmless, but noisy in the event log. - No reusable Engine test harness, which blocks supervisor-restart and exit-code coverage.