No description
  • Python 86.6%
  • TypeScript 11%
  • JavaScript 1.1%
  • Dockerfile 0.5%
  • Makefile 0.5%
  • Other 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
aamon 5b7ad36706
Some checks are pending
ci / ruff + mypy (push) Waiting to run
ci / unit tests (push) Waiting to run
ci / compose config + image builds (push) Waiting to run
ci / integration suite (push) Has been skipped
test(mmc): pin the naked-exit ceiling-pull and duplicate-shed behaviors
The last scheduled run's log flagged both as unfixed HIGHs blocking
deploy; they were in fact already fixed in f4710c0 but had no direct
coverage. These two tests prove the behaviors against the committed
code and correct the run log so the next cycle spends its request
slots elsewhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 07:07:22 +02:00
.claude test(mmc): pin the naked-exit ceiling-pull and duplicate-shed behaviors 2026-08-07 07:07:22 +02:00
.github/workflows feat: implement bot per DESIGN.md — infra, core, exchange, api, dashboard, tests 2026-07-30 00:27:19 +02:00
docker build: fix container networking, add WEB_TARGET dev/prod toggle 2026-08-05 12:10:38 +02:00
docs feat(strategies): pair-completion priority for mmc, tail band for mm_midprice 2026-08-07 01:33:25 +02:00
migrations feat: static per-bot config (strategy + selector rules), settlement and paper-ledger fixes 2026-08-06 15:37:48 +02:00
ops fix(ops): let the scheduled cycle outlive headless's 600s background cap 2026-08-06 21:30:50 +02:00
research docs(research): survey open-source Polymarket bots, condense findings 2026-08-04 15:55:55 +02:00
secrets feat: implement bot per DESIGN.md — infra, core, exchange, api, dashboard, tests 2026-07-30 00:27:19 +02:00
src/pmbot feat(strategies): mmc naked-leg exit, selector spread floor, freshness revert 2026-08-07 07:03:35 +02:00
tests test(mmc): pin the naked-exit ceiling-pull and duplicate-shed behaviors 2026-08-07 07:07:22 +02:00
web feat(strategies): mmc naked-leg exit, selector spread floor, freshness revert 2026-08-07 07:03:35 +02:00
.dockerignore feat: implement bot per DESIGN.md — infra, core, exchange, api, dashboard, tests 2026-07-30 00:27:19 +02:00
.env.example feat(strategies): mmc naked-leg exit, selector spread floor, freshness revert 2026-08-07 07:03:35 +02:00
.gitignore ops: self-contained 6h analyst-dev-cycle watchdog 2026-08-06 20:01:49 +02:00
alembic.ini feat: implement bot per DESIGN.md — infra, core, exchange, api, dashboard, tests 2026-07-30 00:27:19 +02:00
compose.override.yaml build: fix container networking, add WEB_TARGET dev/prod toggle 2026-08-05 12:10:38 +02:00
compose.yaml feat(strategies): mmc naked-leg exit, selector spread floor, freshness revert 2026-08-07 07:03:35 +02:00
Makefile ops: self-contained 6h analyst-dev-cycle watchdog 2026-08-06 20:01:49 +02:00
pyproject.toml feat: implement bot per DESIGN.md — infra, core, exchange, api, dashboard, tests 2026-07-30 00:27:19 +02:00
README.md fix(selector,api): gate on liveness, cap window, lock-free claim 2026-08-04 20:10:54 +02:00
smoke2.log feat: complete bot engine, wire kill chain, fix money-path defects 2026-07-30 08:20:06 +02:00
uv.lock feat: implement bot per DESIGN.md — infra, core, exchange, api, dashboard, tests 2026-07-30 00:27:19 +02:00

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 AGO means 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.

  1. Put your wallet key in secrets/private_key and set PMBOT_FUNDER_ADDRESS in .env to the matching address. The bot asserts these agree and refuses to trade if they do not.
  2. Acknowledge deliberately: echo "acked $(date -u +%FT%TZ)" > secrets/live_ack. An empty file does not count.
  3. 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.
  4. Set PMBOT_MODE=live and lower the risk limits to something you would not mind losing.
  5. 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 mode 0700), never environment variables and never in .env. Both are gitignored.
  • The api container has no exchange access and never loads the wallet key.
  • The dashboard binds to 127.0.0.1 by default. Only widen DASH_BIND behind an authenticating reverse proxy, and widen DASH_EXTRA_ORIGIN_REGEX with 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_reached at ERROR each sweep. Harmless, but noisy in the event log.
  • No reusable Engine test harness, which blocks supervisor-restart and exit-code coverage.