A maker-only market-making bot for Polymarket CLOB V2. It picks its own markets from the Gamma API, quotes both sides post-only around a microprice fair value, and earns liquidity rewards while managing inventory. Single async process, Postgres state.
Warning
This is a research project, not a proven money-maker. Live testing on a small wallet showed the strategy can lose more to adverse selection (informed traders picking off resting quotes) than it earns back in liquidity rewards — the rewards alone did not cover the losses. Nothing here is investment advice, and past behavior on Polymarket is no guarantee of future behavior on any market. Read the code, don't just run it. If you do run it, use money you can afford to lose in full, start at the smallest size the venue allows, and treat the paper/live gates below as a safety net, not a substitute for understanding what the bot does.
The engine runs in paper mode (simulated fills, no real orders) until two
independent gates are cleared (TRADING_HALTED=0 and MAKER_LIVE=1).
Polymarket pays liquidity rewards to makers who rest orders close to the midpoint of a market's order book — the tighter and larger the resting order, the bigger its share of that market's daily reward pool. This bot tries to collect that income on autopilot: it scans the market catalog, picks a handful of markets worth quoting, and continuously posts two-sided limit orders (a buy on YES and a buy on NO) sized and priced to score rewards, while trying to keep its own directional risk small.
The catch, and the reason for the warning above: resting orders can also be picked off. A trader with better or faster information can trade against a stale quote before the bot reprices, and that adverse selection is a real cost that has to be measured against the reward income, not assumed away. Whether the balance is favorable depends on which markets are quoted and how aggressively — which is most of what the code below is trying to get right.
Not every market is worth quoting, and picking a bad one is not a rounding error — at low market count the top of the ranking basically is the strategy. Selection happens in two stages:
- Scanning (
maker/catalog/scoring.py) — a cheap, no-book-fetch score over the whole Gamma catalog, combining reward density with a rebate estimate and a spread penalty. This is only a proxy: it decides which markets are worth the cost of a real book fetch, not which one ultimately gets picked. - Feasibility (
maker/catalog/feasibility.py) — for the shortlisted markets, fetch the live order book and compute the actual expected reward given competing maker liquidity already resting in the reward band. A market can look great on the cheap score and pay nothing once the real competition is accounted for.
On top of both, maker/catalog/selection.py
applies hard eligibility floors — minimum liquidity, minimum time to
resolution, maximum recent price volatility relative to the reward band, and a
few category exclusions for markets prone to sudden, hard-to-price jumps (e.g.
live-event and breaking-news-driven markets). A market below any floor is
unselectable no matter how attractive its score is. What clears the floors is
then ranked by the measured dollar estimate, not the cheap proxy, and rotated
in and out slowly on purpose — churn (cancel/replace, requoting) has its own
cost in lost queue position and reward accrual.
The core pricing logic lives in
maker/strategy/quoting.py and is a pure
function: (market state, inventory, params) -> target quotes, with no I/O, so
it's fully unit-testable.
reservation_price = fair_value - inventory_skew
half_spread = base + c_vol * short_term_volatility + c_tox * toxicity_estimate
YES bid = reservation_price - half_spread
NO bid = (1 - reservation_price) - half_spread
Both legs are bids — the bot never sells YES or NO directly to enter a
position, it always buys both sides. A filled matching pair (some YES + some
NO) can be merged back into risk-free USDC collateral, which is what makes
two-sided quoting close to self-funding when it works. fair_value is a
microprice estimate nudged by recent order-flow direction; the half-spread
widens with measured short-term volatility and a running "toxicity" estimate
(how much the market has tended to move against the bot's own recent fills),
so the bot backs off automatically in choppier or more adverse conditions. In
calm regimes, quotes are pulled in toward the reward program's own scoring band
so they actually earn rewards rather than sitting uselessly wide.
Above the pure pricing function, the engine (maker/engine/)
adds regime classification (quiet / trending / event-driven), inventory and
exposure limits, stale-mark and kill-switch checks, and order lifecycle
management (place, cancel, reconcile against the exchange).
All risk-appetite numbers — position size, daily loss limit, cumulative
drawdown limit, per-market caps, stale-mark timeouts — live in one file,
maker/presets.py, deliberately separate from the code
that loads them. There is no partial config: every knob has to be set
explicitly or the process refuses to start.
flowchart TD
MD[Market Data In<br/>book WS + trade prints] --> WAKE[Wake Quoter<br/>debounce / slow tick]
WAKE --> FV{Two-sided<br/>book?}
FV -- No --> EMPTY[Empty Quote Set]
FV -- Yes --> PRICE[Fair Value<br/>microprice + flow nudge]
PRICE --> MARK[Update Marks<br/>+ markouts]
MARK --> BLIND{Blind or<br/>kill switch?}
BLIND -- Yes --> PULL[Cancel All Quotes]
BLIND -- No --> RISK[Risk Assessment<br/>exposure, daily loss, stale marks]
RISK --> LIM{Within<br/>limits?}
LIM -- Outside Limits --> RO[Reduce-Only / Halt<br/>exits only]
LIM -- Within Limits --> REG[Classify Regime<br/>QUIET / TRENDING / EVENT]
RO --> BUILD
EMPTY --> RECON
PULL --> RECON
REG --> BUILD[Build Quotes<br/>half-spread + inventory skew + ladder]
BUILD --> RECON{Differs from<br/>resting orders?}
RECON -- No --> MERGE[Merge YES+NO Pairs<br/>back to collateral]
RECON -- Yes --> CANCEL[Cancel Stale Orders]
CANCEL --> PLACE[Place Orders<br/>post-only]
PLACE --> OM[Order Management<br/>track ids, reconcile vs REST]
OM --> FILL{Fill<br/>received?}
FILL -- No --> WAKE
FILL -- Yes --> POS[Update Position<br/>cash, toxicity, markout]
POS --> MERGE
MERGE --> WAKE