Verified: 2026-06-28 by reading the live docs. The rest of the build references this file, not assumptions. Re-verify before changing any client. Sources read this round:
- https://docs.polymarket.com and https://docs.polymarket.com/llms.txt (doc index)
- https://docs.polymarket.com/api-reference/rate-limits.md
- https://docs.polymarket.com/trading/fees.md
- https://docs.polymarket.com/advanced/neg-risk.md
- https://docs.polymarket.com/concepts/pusd.md
- https://docs.polymarket.com/api-reference/wss/market.md
- https://github.com/Polymarket/py-clob-client and https://github.com/Polymarket/py-sdk
| Service | Base URL | Purpose | Auth for reads |
|---|---|---|---|
| Gamma | https://gamma-api.polymarket.com |
Discovery: events, markets, negRisk flags, metadata | None |
| CLOB | https://clob.polymarket.com |
Order books, prices, midpoints, fees, tick/min size | None for reads |
| Data | https://data-api.polymarket.com |
Positions, trades, holders, PNL | None |
| WebSocket | wss://ws-subscriptions-clob.polymarket.com/ws/market |
Live public orderbook & price updates | None (market) |
CLOB trading (POST/DELETE order) requires API credentials + a signing client.
WebSocket user channel (/ws/user) requires auth. The detector touches none of these.
Docs say excess requests are throttled/queued, not 429-rejected — but still implement a token bucket + backoff defensively (Cloudflare can still 429).
- Gamma — general 4,000/10s;
/events500/10s;/markets300/10s;/public-search350/10s;/comments,/tags200/10s each. - Data — general 1,000/10s;
/trades200/10s;/positions,/closed-positions150/10s each. - CLOB — general 9,000/10s; market data (
/book,/price,/midpoint) 1,500/10s. Trading (not used by detector):POST/DELETE /order5,000/10s burst, 120,000/10min sustained;POST/DELETE /orders2,000/10s burst, 21,000/10min;DELETE /cancel-all250/10s burst, 6,000/10min. - WebSocket — no concurrent-connection cap documented (do not assume unlimited; subscribe many assets on one connection rather than fanning out connections).
- Other: Bridge 50/10s; Relayer
/submit25/min; User PNL 200/10s.
- Current collateral is pUSD ("Polymarket USD"), a standard ERC-20 on Polygon, backed 1:1 by USDC. The protocol settles in native USDC underneath.
- Wrap (USDC.e → pUSD):
CollateralOnrampat0x93070a847efEf7F70739046A929D47a521F5B8ee. Unwrap viaCollateralOfframp(address not captured — re-fetch if needed for execution). - For the detector this only matters for naming/units (treat 1 unit = 1 pUSD = 1 USD). Split/merge and NegRisk-convert operate on pUSD collateral. Relevant for Phase 5 only.
-
Makers pay no fee. Taker fee only. Formula:
fee = C × feeRate × p × (1 - p)whereC= shares traded,p= share price.Fee is maximal at p=0.50 and → 0 at the extremes. Charged in USDC at match time; orders carry no fee field.
-
feeRate by category (verify per-market, these are category defaults): Crypto 0.07 · Sports 0.03 · Finance/Politics/Tech/Mentions 0.04 · Economics/Culture/Weather/Other 0.05 · Geopolitics & world events 0 (fee-free).
-
Per-market fee params come directly on the Gamma market object:
feesEnabled(bool),feeType(e.g.crypto_fees_v2,culture_fees,sports_fees_v2,general_fees, ornullfor fee-free), andfeeSchedule(object or null). Fee-free markets havefeesEnabled:false,feeType:null,feeSchedule:null. -
Full
feeScheduleshape (verified 2026-06-30 against live Gamma/marketsacross crypto/sports/culture/general categories — 20 markets sampled):{ "exponent": 1, "rate": 0.07, "takerOnly": true, "rebateRate": 0.25 }exponent: 1confirms the formula isp^1 × (1-p)^1(pure parabolic, as implemented).takerOnly: trueconfirms makers pay zero.rebateRateis a maker-side rebate (does not affect taker fee). Nomin,floor,minimum, or equivalent field exists. Onlyrateis surfaced asMarket.fee_rate; the others are informational. -
Computed fees are rounded to 5 dp; <0.00001 USDC rounds to zero.
-
Implication for
MIN_PROFIT_BPS: threshold should be lower for fee-free categories, higher for fee'd ones — derive per market from live params, per SPEC.
Backlog item M3-feefloor raised concern that a per-order fee minimum would make the parabolic model understate fees on longshot legs (p → 0/1), producing false positives.
Verification method: live read-only recon via GET /markets?limit=20 on the Gamma API
(no auth, no order placement). Inspected feesEnabled, feeType, and the full feeSchedule
object for all 4 fee-type patterns present in the sample (crypto 0.07, sports 0.03, culture
0.05, general 0.05).
Finding: the feeSchedule object has exactly four fields (exponent, rate,
takerOnly, rebateRate). No min, floor, minimum, or any analogous field was present
in any sampled market. The formula C·r·p·(1−p) (with exponent=1) is the complete taker
cost; fee approaching zero at the price extremes is correct, not a modeling gap.
Decision: no code change to taker_fee. A pinning test (test_m3_no_fee_floor_at_longshot_prices
in tests/test_fees.py) locks the intentional behavior and serves as a trip-wire if Polymarket
later introduces a floor. Re-investigate if the feeSchedule schema gains new fields or if
Polymarket documentation explicitly describes a per-order minimum.
- Detect via the event object:
negRisk: true. Augmented (outcomes can be added later) showsenableNegRisk: trueandnegRiskAugmented: true. - Convert (NegRisk Adapter contract): a No share in one market converts into 1 Yes share in every other market in the event, atomically. This is a capital-efficiency tool — pay 1, get 1, no profit. Arb profit comes from buying the underpriced YES basket through the standard order books, not from convert. (Encode this in code + docstring + test, per SPEC.)
- Standard markets are independent; negRisk links all markets in an event.
- Merge of a single constituent's YES+NO → $1 is valid and FEE-FREE (verified 2026-06-29
against
NegRiskAdapter.sol, Polymarket/neg-risk-ctf-adapter). The YES/NO of one NegRisk constituent are genuine CTF complementary pairs;NegRiskAdapter.mergePositionsreturns exactly the mergedamountin USDC with no protocol fee — thefeeBipscharge applies only toconvertPositions, not to merge. Consequence: the complement-arb cost modelprofit = 1 − cost − fees − gasis correct for NegRisk constituents; do NOT exclude them. Phase-5 (execution) caveats only: the merge must callNegRiskAdapter.mergePositions(Polygon0x59c8b7221766b8f06c8484d9b679fa0ac72050d7), not the CTF directly, and it costs slightly more gas (an extra internalwcol.unwrap) — a per-execution gas model should use a higher constant fornegRiskmarkets. Merge is still a single, instant tx (no resolution wait).
-
Event-level id:
id. NegRisk flag (negRisk) lives on the event AND each sub-market;negRiskMarketIDis shared across all sub-markets of a negRisk event. -
A Gamma market carries
conditionId(on-chain market key) andclobTokenIds— a JSON-encoded string of[YES_token_id, NO_token_id], index-aligned withoutcomes(outcomes[0]="Yes" →clobTokenIds[0]=YES token).outcomePricesis encoded the same way. The CLOB book echoesmarket(=conditionId) andasset_id(=token_id). -
A token_id is the order/quote identifier (a.k.a.
asset_id), one per outcome side. -
Quirks the models normalize (see
src/polyarb/models.py): JSON-string list fields are decoded; bookbids/askscome sorted worst→best so best bid/ask are taken by value;feeSchedule.rate→fee_rate; some markets omitclobTokenIds(not yet tradeable) and some token_ids 404 on/book. -
/markets?closed=true&condition_ids=...(E1 settle poller — VERIFIED LIVE 2026-07-01): the read-onlypolyarb settlepoller (GammaClient.resolved_markets) fetches resolved markets bycondition_ids. Confirmed: thecondition_idsfilter +active=false/closed=truepairing works — asked for N condition_ids, got exactly those N back asclosed=true. -
Resolved
outcomePricesare NOT exact 0/1 (VERIFIED LIVE 2026-07-01 — corrected an E1 bug): a resolved binary'soutcomePricesis the last-mid at close, converging near the payout but not to it — winners come back ~0.9999 (seen down to ~0.9664), losers ~1e-6; a genuine 50-50 void sits near 0.5. So settlement must round to the nearest payout {0, 0.5, 1}, not test exact equality (the originalsettleflagged every real resolution as a "void").settlenow rounds with a middle void band [0.25, 0.75]. Limitation: a legitimately-resolved market whose last-mid landed in that band is conservatively treated as a void (triggers an E2 review alert, never a silently-wrong P&L). A rare[0,0]/malformed payload rounds both legs to 0. -
Per-order minimum size = 5 shares (VERIFIED LIVE 2026-07-01): every live market sampled (100/100) reports
orderMinSize(→Market.min_order_size) = 5;orderPriceMinTickSize(→tick_size) is 0.001 (~94%) or 0.01. The 5-share minimum is what the D5 executability gate (enforce_min_order_size) enforces: a basket leg placing fewer than 5 shares isn't fillable, so a reported edge on it is a phantom. Redundant at the $50 floor; the safety gate for any lower floor.
- URL:
wss://ws-subscriptions-clob.polymarket.com/ws/market. - Subscribe:
{"assets_ids": ["<token_id>", ...], "type": "market"}. Optional:initial_dump(bool, default true — snapshot on subscribe),level(1–3, default 2),custom_feature_enabled(default false). - Dynamic (re)subscription without reconnect:
{"operation": "subscribe"|"unsubscribe", "assets_ids": ["<token_id>"]}. - Initial-dump frame size (live-verified 2026-07-01): the
initial_dumpsnapshot is sent as a single frame whose size scales with the number of subscribed tokens — measured ~1.65 MiB for ~390 tokens (~4.3 KiB/token), so the 600-market default (~1200 tokens) is several MiB. ThewebsocketsPython library caps inbound frames at 1 MiB by default and closes the connection immediately with 1009 MESSAGE_TOO_BIG — the feed then never delivers a message and a runner silently rides on the REST-resync backup. Setwebsockets.connect(max_size=...)generously (we use 64 MiB, configWS_MAX_MESSAGE_BYTES). Deltas (price_change) are small; only the initial dump is large.
py-clob-clientis ARCHIVED and "no longer functional" — the README explicitly says do not use it for new or existing integrations. SPEC.md names py-clob-client; that is now stale.- Replacement: the new unified SDK
py-sdk(https://github.com/Polymarket/py-sdk), pip packagepolymarket-client(beta — install withpip install --pre polymarket-client). ExposesPublicClient/AsyncPublicClient(e.g.get_market(...)) and presumably the signing/trading client for execution. Python version unconfirmed. - Decision needed before Phase 5: swap the execution module to
polymarket-client. Detection (Phases 1–4) useshttpxdirectly against the REST endpoints above and does not depend on either SDK, so this does not block the detector.
get_ok, get_server_time, get_midpoint(token_id), get_price(token_id, side),
get_order_book(token_id), get_order_books([BookParams(...)]), get_simplified_markets()
— all no-auth. We will call the equivalent REST endpoints directly via httpx rather than
depend on the archived client.
Verdict: on the relayer path, Polymarket pays gas; true user cost ≈ $0. Polymarket uses a GSN-style meta-transaction relayer: the user signs locally, the relayer broadcasts and pays the Polygon gas (docs.polymarket.com/trading/gasless). Gas responsibility by wallet:
| Wallet type | Gas payer |
|---|---|
| Proxy (Magic/Google login) | Relayer (Polymarket) |
| Safe (Gnosis) | Relayer |
| Deposit wallet (API V2 default) | Relayer |
| Raw EOA (bare private key) | User pays |
- CLOB orders: placement/cancel are off-chain signed messages (no gas, always). Fill
settlement (
matchOrders) is relayer-paid for proxy/Safe/deposit wallets. - CTF split / merge / redeem (incl. NegRisk merge/redeem): explicitly relayer-covered ("CTF operations: Split, merge, and redeem positions" — docs.polymarket.com/trading/gasless; github.com/Polymarket/agent-skills gasless.md).
Config defaults (config.py): gas_estimate=0.02, gas_per_leg_estimate=0.05 USDC — a
deliberately conservative ceiling (true relayer cost ≈ $0), set non-zero only to cover the
raw-EOA / undocumented relayer-cap edge. Derivation (conservative): gas units from real
Polygonscan receipts — CTF merge ~125–143k, redeem ~170–192k, NegRisk redeem ~208k, exchange
matchOrders 380–670k — at a ceiling 600 gwei (historical floor ~25–30; mid-2026 congested at
245–500) and POL ≈ $0.10 ⇒ ~$0.015 fixed (250k gas) and ~$0.042/leg (700k gas), rounded up.
If the executor is confirmed relayer-only, set both ~0.
Live (dynamic) gas — keyless endpoints for the future use_dynamic_gas client:
- Polygon gas price (gwei):
GET https://gasstation.polygon.technology/v2→ usestandard.maxFee(orfast.maxFee); poll ≤ 1/block (~2s). (The old polygonscan oracle is dead → Etherscan v2, which now needs a key; prefer the Gas Station.) - POL/USD:
GET https://api.coingecko.com/api/v3/simple/price?ids=polygon-ecosystem-token&vs_currencies=usd→["polygon-ecosystem-token"]["usd"]. (The oldmatic-networkid is deprecated →{}.)
Unconfirmed / source later: relayer daily gas-subsidy cap (exists, $ figure unpublished —
ask Polymarket / Builder Program); NegRisk mergePositions/convertPositions gas (no recent
on-chain sample — capture a receipt if executed); EOA per-leg attribution (matchOrders
settles the whole match, so 0.05/leg over-counts — refine against the real execution path).