commit 1d4d3a22f4ef32834e9194cf830006980c330d52 Author: GoldPm Date: Mon Jun 22 16:07:29 2026 +0800 Initial commit: Polymarket skill (Gamma/Data/CLOB/Bridge/Relayer client + CLI) Public market data via direct HTTP; authenticated CLOB trading via the official py-clob-client-v2 SDK. Includes README, endpoint reference, and trading/auth guide. Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9f1b45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Secrets — NEVER commit wallet keys or env files +.env +.env.* +*.key +*.pem +secrets* +*private*key* + +# OS / editor +.DS_Store +.idea/ +.vscode/ +*.swp + +# Packaged skill artifact +*.skill diff --git a/README.md b/README.md new file mode 100644 index 0000000..27ef6a7 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# Polymarket Skill + +一个让 Claude / 命令行操作 [Polymarket](https://polymarket.com) 的技能包:查询行情、事件、盘口、价格、持仓、成交、排行榜等公开数据,以及在 CLOB 上真实下单 / 撤单 / 查单。 + +A skill for driving the Polymarket APIs (Gamma, Data, CLOB, Bridge, Relayer) from +Python — read market data and place/cancel/query real orders. + +--- + +## 安装 Install + +从 git 仓库安装(把仓库克隆为技能目录): + +```bash +git clone <仓库地址> ~/.claude/skills/polymarket +``` + +或双击预打包的 `polymarket.skill` 由 Claude 桌面端安装,或把目录放进 `~/.claude/skills/`。 + +依赖: + +```bash +pip install requests # 公开数据(行情/盘口/持仓/排行榜),无需账户 +pip install py-clob-client-v2 # 仅真实交易时需要(官方 v2 SDK) +``` + +> macOS/Homebrew 若报 `externally-managed-environment`,用虚拟环境: +> `python3 -m venv .venv && source .venv/bin/activate`,激活后再 `pip install`。 + +--- + +## 快速开始:查数据(无需任何配置) + +```bash +cd scripts +python polymarket.py list-commands # 列出全部命令 +python polymarket.py gamma.list_markets --json '{"limit":5,"closed":false}' +python polymarket.py gamma.search --q "world cup" +python polymarket.py clob.midpoint --token_id +python polymarket.py clob.order_book --token_id +python polymarket.py data.positions --user 0xADDR --json '{"limit":50}' +python polymarket.py data.leaderboard --json '{"limit":10}' +``` + +简单参数用 `--key value`;结构化参数(数字/布尔/多参数)用一个 `--json '{...}'`。 + +**ID 概念**:`slug`(市场/事件的 URL 片段)、`condition_id`(标识一个市场)、 +`token_id`(某个结果 YES/NO 的资产 id,盘口/价格/历史都按它查)。从 +`gamma.list_markets` / `gamma.get_market` 返回的 `clobTokenIds` 拿 token_id。 + +Python 里用: + +```python +import sys; sys.path.insert(0, "scripts") +from polymarket import GammaClient, DataClient, ClobPublicClient +g = GammaClient() +markets = g.list_markets(limit=10, closed=False, order="volume24hr", ascending=False) +book = ClobPublicClient().order_book(token_id="...") +``` + +--- + +## 真实交易(动用钱包私钥与真金白银)⚠️ + +每个人用自己的钱包。配置环境变量后从 Python 调用: + +```bash +export PK=0x你的私钥 +export POLY_PROXY_ADDRESS=0x你的交易账户地址 # 代理钱包持有 USDC 的地址 +export POLY_SIGNATURE_TYPE=... # 0 / 1 / 2 / 3,见下 +``` + +```python +import sys; sys.path.insert(0, "scripts") +from polymarket import TradingClient +t = TradingClient() # 首次构造自动派生 L2 API 凭证 + +t.get_balance_allowance() # 查 USDC 余额 + 授权 +t.create_and_post_order(token_id="...", price=0.42, size=10, side="BUY", order_type="GTC") +t.create_and_post_market_order(token_id="...", amount=5, side="BUY") # amount=USDC金额 +t.get_open_orders(asset_id="...") +t.cancel_all() +``` + +### 签名类型(每个账户不同,务必选对) + +| 注册方式 | `signature_type` | +|---|---| +| 自有钱包直接充值的 EOA | `0` | +| 旧版 邮箱/Magic 登录 | `1` | +| 浏览器钱包(MetaMask 等) | `2` | +| 新版 邮箱/Google 内嵌钱包 | `3` | + +**关键坑**:类型选错会让 `get_balance_allowance` 静默返回 `balance: 0`(查错了代理)。 +若认证成功(凭证 + 签名地址正确)但余额是 0,**逐个试 `signature_type=1/2/3`**;当 +`balance` 等于你网页上的现金、且三个 `allowances` 是一个很大的数(≈2^256-1)时即为正确类型。 +新账户首次交易需先在网页端成交一笔以部署代理 + 设置授权。 + +详见 `references/trading.md`。 + +--- + +## 文件结构 + +``` +polymarket/ +├── SKILL.md # 技能说明(给 Claude 读)+ 用法 +├── README.md # 本文件 +├── scripts/polymarket.py # 客户端 + CLI(78+ 公开命令 + 交易) +└── references/ + ├── endpoints.md # 每个端点 → 方法/路径/参数 全表 + └── trading.md # 认证、签名类型、下单类型、安全 +``` + +--- + +## 安全须知 + +- 私钥 = 钱包完全控制权。**只在自己的终端 `export`,绝不写入文件、不贴给任何人、不进 git。** +- 交易是真实、难以撤销的资金操作。先小额测试,量力而行。 +- 本工具调用的是 Polymarket 内部 API,可能随其更新而变化;若某端点 404,见 + `references/endpoints.md` 核对路径。 +- 仅供学习与个人使用;遵守 Polymarket 的服务条款与你所在地区的法律法规。 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..80350cd --- /dev/null +++ b/SKILL.md @@ -0,0 +1,134 @@ +--- +name: polymarket +description: >- + Interact with the Polymarket prediction-market APIs (Gamma, Data, CLOB, + Bridge, Relayer) from Python: browse and search Polymarket markets and events; + read prices, order books, midpoints, spreads and price history; look up a + wallet's positions, trades, activity, PnL, holders, leaderboard, open interest + and liquidity-mining rewards; and place, cancel or query real CLOB orders. + Trigger whenever the user wants Polymarket market data or wants to trade on + Polymarket, even without naming an endpoint — including Chinese requests like + 查询/看 Polymarket 的行情、赔率、订单簿、持仓、成交、盈亏、排行榜、奖励,或下单/ + 挂单/撤单/交易; answer in Chinese when the user writes in Chinese. Specific to + Polymarket: do NOT use for other venues (Kalshi, DraftKings/sportsbooks, + Binance, Coinbase, Robinhood), for conceptual questions about how prediction + markets work or their legality, or for pure opinion/comparison of platforms. +--- + +# Polymarket API + +A Python client (`scripts/polymarket.py`) covering the full Polymarket REST +surface. Public read endpoints use plain HTTP; authenticated **trading** is +delegated to the official `py_clob_client_v2` (the docs recommend it — +hand-rolling EIP-712 order signing risks real funds). + +When the user writes in Chinese, answer in Chinese (tables, labels, and summary +in 中文); keep API field names, token ids, and command snippets as-is. + +## The four+1 APIs + +| API | Base URL | Auth | What it has | +|-----|----------|------|-------------| +| Gamma | gamma-api.polymarket.com | none | markets, events, tags, series, comments, sports, search, public profiles | +| Data | data-api.polymarket.com | none | positions, trades, activity, holders, value, leaderboard, open interest | +| CLOB (public) | clob.polymarket.com | none | order book, price, midpoint, spread, tick size, price history, markets | +| CLOB (trade) | clob.polymarket.com | L1+L2 | place / cancel / query orders, balances, API keys | +| Bridge / Relayer | bridge / relayer-v2 | varies | deposits, withdrawals, proxy-wallet tx | + +## Setup + +```bash +pip install requests # public endpoints +pip install py_clob_client_v2 # only needed for authenticated trading (official v2 SDK) +``` + +## Reading data (no auth) — use the CLI + +The fastest path. Every public method is exposed as `.`: + +```bash +cd scripts +python polymarket.py list-commands # see everything +python polymarket.py gamma.list_markets --json '{"limit":5,"closed":false}' +python polymarket.py gamma.search --q "election" +python polymarket.py gamma.get_event_by_slug --slug some-event-slug +python polymarket.py clob.order_book --token_id 7193... # full book +python polymarket.py clob.midpoint --token_id 7193... +python polymarket.py clob.prices_history --market 7193... --json '{"interval":"1d","fidelity":60}' +python polymarket.py data.positions --user 0xABC... --json '{"limit":50}' +python polymarket.py data.trades --json '{"user":"0xABC...","limit":20}' +python polymarket.py data.leaderboard --json '{"window":"7d","limit":10}' +``` + +Pass simple flags as `--key value`; pass anything structured (numbers, bools, +lists, multiple params) as one `--json '{...}'`. Both can be combined. + +Key id concepts the user will hand you: +- **slug** — human URL fragment of an event/market. +- **condition_id** — identifies a market (a yes/no question) on-chain. +- **token_id (clob)** — the ERC1155 asset id of *one outcome* (YES or SELL side). + Order book / price / midpoint / price-history all key on token_id. + +To go from a market to its token ids: `gamma.list_markets` / `gamma.get_market` +return `clobTokenIds`. Use those with the `clob.*` pricing calls. + +## Reading data from Python + +```python +import sys; sys.path.insert(0, "scripts") +from polymarket import GammaClient, DataClient, ClobPublicClient +g = GammaClient() +markets = g.list_markets(limit=10, closed=False, order="volume24hr", ascending=False) +book = ClobPublicClient().order_book(token_id="7193...") +pos = DataClient().positions(user="0xABC...", limit=100) +``` + +## Trading (authenticated — real money) + +Trading needs the user's wallet private key, so it runs from Python with env +vars, never through the flat CLI. **Before placing any order, confirm with the +user the token_id, side, price, and size** — these move real funds and are not +easily reversible. + +```bash +export PK=0xYOUR_PRIVATE_KEY +export POLY_PROXY_ADDRESS=0xYOUR_PROXY # if using a Polymarket proxy wallet +``` + +```python +from polymarket import TradingClient +t = TradingClient() # auto-derives L2 API creds on first run + +# Limit order: buy 10 shares of outcome token at $0.42 +t.create_and_post_order(token_id="7193...", price=0.42, size=10, side="BUY", + order_type="GTC") + +# Market order by USDC amount +t.create_and_post_market_order(token_id="7193...", amount=20, side="BUY") + +t.get_open_orders() # open orders +t.cancel_order(payload="0x...") # cancel one +t.cancel_all() # cancel everything +t.get_trades() # your fills +t.get_balance_allowance() + +# L2-authenticated rewards / rebates (read-only, signed for you) +t.rewards_user_earnings(date="2026-06-22") +t.rebates_current(date="2026-06-22", maker_address="0x...") +``` + +`TradingClient` wraps the official **v2** SDK (`py_clob_client_v2`): L1 (EIP-712) +order signing and L2 (HMAC-SHA256 `POLY_*` headers) are handled automatically. +The user-scoped rewards/rebates GETs aren't exposed by the SDK, so the skill +signs those L2 requests itself. Set `POLY_SIGNATURE_TYPE` (0 EOA / 1 proxy / +2 gnosis-safe) if you hit balance/allowance errors. See `references/trading.md`. + +## When you need an endpoint that isn't obvious + +`references/endpoints.md` maps every documented Polymarket endpoint to its +client method and notes the exact query parameters. Read it when the user asks +for something specific (rewards, rebates, combo markets, builder analytics, +maker quotes, accounting snapshots) so you call the right method with the right +params. These tools hit **undocumented-stability** internal APIs and can change; +if a call 404s, check the live path in `references/endpoints.md` and the docs at +https://docs.polymarket.com/api-reference. diff --git a/references/endpoints.md b/references/endpoints.md new file mode 100644 index 0000000..007d954 --- /dev/null +++ b/references/endpoints.md @@ -0,0 +1,138 @@ +# Polymarket endpoint catalog + +Every documented endpoint mapped to a client method in `scripts/polymarket.py`. +Source of truth: https://docs.polymarket.com/api-reference (and `/llms.txt`). +Paths marked ⚠ are internal/best-effort — verify against the live docs if a call +fails, and adjust the method body in `polymarket.py`. + +## Gamma API — `GammaClient` (gamma-api.polymarket.com) + +| Capability | Method | Path | Key params | +|---|---|---|---| +| List markets | `list_markets()` | GET /markets | limit, offset, order, ascending, id, slug, clob_token_ids, condition_ids, tag_id, closed, liquidity_num_min/max, volume_num_min/max, start_date_min/max, end_date_min/max | +| Market by id | `get_market(id)` | GET /markets/{id} | | +| Market tags | `get_market_tags(id)` | GET /markets/{id}/tags | | +| List events | `list_events()` | GET /events | limit, offset, order, ascending, slug, tag_id, closed, active | +| Events (keyset) | `list_events_paginated()` | GET /events/pagination | | +| Event by id | `get_event(id)` | GET /events/{id} | | +| Event by slug | `get_event_by_slug(slug)` | GET /events/slug/{slug} | | +| Event tags | `get_event_tags(id)` | GET /events/{id}/tags | | +| List tags | `list_tags()` | GET /tags | limit, offset | +| Tag by id / slug | `get_tag(id)` / `get_tag_by_slug(slug)` | GET /tags/{id}, /tags/slug/{slug} | | +| Related tags | `get_related_tags_by_id/slug`, `get_tags_related_to_id/slug` | GET /tags/{id}/related-tags[/tags] | | +| List / get series | `list_series()` / `get_series(id)` | GET /series, /series/{id} | | +| List comments | `list_comments()` | GET /comments | limit, offset, parent_entity_type, parent_entity_id | +| Comments by id | `get_comments_by_id(id)` | GET /comments/{id} | | +| Comments by user | `get_comments_by_user(addr)` | GET /comments/user_address/{addr} | | +| Search | `search(q)` | GET /public-search | q, limit_per_type, events_status | +| Sports metadata | `get_sports_metadata()` | GET /sports | | +| Sports market types | `get_sports_market_types()` | GET /sports/market-types | | +| List teams | `list_teams()` | GET /teams | league, name | +| Public profile | `get_public_profile(addr)` | GET /public-profile/{addr} | | + +## Data API — `DataClient` (data-api.polymarket.com) + +| Capability | Method | Path | Key params | +|---|---|---|---| +| Current positions | `positions(user)` | GET /positions | user, market, eventId, sizeThreshold, redeemable, mergeable, limit(≤500), offset, sortBy, sortDirection, title | +| Closed positions | `closed_positions(user)` | GET /closed-positions | user, limit, offset | +| Positions in a market | `market_positions(market)` | GET /positions | market=conditionIds | +| Total value | `value(user)` | GET /value | user, market | +| User activity | `activity(user)` | GET /activity | user, limit, offset, market, type, side, start, end | +| Combo activity | `combo_activity(user)` | GET /combo-activity | user | +| Combo positions | `combo_positions(user)` | GET /combo-positions | user | +| Trades | `trades()` | GET /trades | user, market, limit, offset, takerOnly, side | +| Top holders | `holders(market)` | GET /holders | market, limit | +| Leaderboard | `leaderboard()` | GET /v1/leaderboard | category, timePeriod, orderBy(Pnl/Vol), limit(≤50), offset, user, userName | +| Markets traded count | `traded(user)` | GET /traded | user | +| Open interest | `open_interest()` | GET /oi | market | +| Live event volume | `live_volume_event(id)` | GET /live-volume | id | +| Builder leaderboard | `builder_leaderboard()` | GET /v1/builders/leaderboard | timePeriod(DAY/WEEK/MONTH/ALL), limit(≤50), offset | +| Builder volume series | `builder_volume()` | GET /v1/builders/volume | timePeriod | +| Accounting snapshot (ZIP) | `accounting_snapshot(user)` | GET /v1/accounting/snapshot | user — saves positions.csv+equity.csv to disk | + +## CLOB public — `ClobPublicClient` (clob.polymarket.com) + +| Capability | Method | Path | +|---|---|---| +| Order book (single) | `order_book(token_id)` | GET /book | +| Order books (batch) | `order_books([token_ids])` | POST /books | +| Price (one side) | `price(token_id, side)` | GET /price | +| Prices (batch) | `prices([{token_id,side}])` | POST /prices | +| Midpoint / midpoints | `midpoint`, `midpoints` | GET /midpoint, POST /midpoints | +| Spread / spreads | `spread`, `spreads` | GET /spread, POST /spreads | +| Last trade price(s) | `last_trade_price`, `last_trade_prices` | GET /last-trade-price, POST /last-trades-prices | +| Tick size | `tick_size(token_id)` | GET /tick-size | +| Fee rate | `fee_rate_bps()` | GET /fee-rate-bps | +| Price history | `prices_history(market)` | GET /prices-history — params: interval(1m/1h/6h/1d/1w/max), startTs, endTs, fidelity | +| Market by condition id | `get_market(condition_id)` | GET /markets/{condition_id} | +| List markets | `list_markets(next_cursor)` | GET /markets | +| Simplified markets | `simplified_markets(next_cursor)` | GET /simplified-markets | +| Sampling (reward) markets | `sampling_markets`, `sampling_simplified_markets` | GET /sampling-markets, /sampling-simplified-markets | +| Server time | `server_time()` | GET /time | +| Rewards: active configs | `rewards_markets_current()` | GET /rewards/markets/current — sponsored, next_cursor | +| Rewards: markets w/ rewards | `rewards_markets_multi()` | GET /rewards/markets/multi — q, tag_slug, event_id, order_by, min/max_volume_24hr, min/max_spread, min/max_price, page_size | +| Rewards: raw for a market | `rewards_market(condition_id)` | GET /rewards/markets/{condition_id} — sponsored | +| Builder-attributed trades | `builder_trades(builder_code)` | GET /builder/trades — id, market, asset_id, before, after | + +## CLOB authenticated trade — `TradingClient` (via py-clob-client) + +Backed by the official **v2** SDK `py_clob_client_v2`. + +| Capability | Method | Notes | +|---|---|---| +| Create / derive API key | `create_or_derive_api_key(nonce=None)` | L1 EIP-712 | +| Post limit order | `create_and_post_order(token_id, price, size, side, order_type)` | order_type GTC/FOK/FAK | +| Post market order | `create_and_post_market_order(token_id, amount, side, order_type)` | FOK/FAK | +| Cancel one | `cancel_order(payload)` | order hash/id | +| Cancel many/all/market | `cancel_orders(hashes)`, `cancel_all()`, `cancel_market_orders(payload)` | | +| Get order | `get_order(order_id)` | L2 | +| Get open orders | `get_open_orders(market=, asset_id=, id=)` | L2; filters optional, else all pages. Fresh orders may lag indexing | +| Get trades | `get_trades(market=, asset_id=, maker_address=, before=, after=)` | L2 | +| Order scoring | `is_order_scoring(order_id)`, `are_orders_scoring(order_ids)` | reward eligibility | +| Balance/allowance | `get_balance_allowance(params)`, `update_balance_allowance(params)` | | + +### L2-authenticated rewards / rebates (signed by the skill, not the SDK) + +| Capability | Method | Path | +|---|---|---| +| User earnings by date | `rewards_user_earnings(date)` | GET /rewards/user | +| User total earnings by date | `rewards_user_total(date)` | GET /rewards/user/total | +| User reward percentages | `rewards_user_percentages()` | GET /rewards/user/percentages | +| User earnings + markets config | `rewards_user_markets()` | GET /rewards/user/markets | +| Maker rebated fees | `rebates_current(date, maker_address)` | GET /rebates/current | + +All accept `signature_type`, `maker_address`, `sponsored`, `next_cursor` etc. as +kwargs. They use `_l2_get`, which builds the HMAC-SHA256 `POLY_*` headers from +the derived creds. + +Maker (RFQ) quote endpoints — submit_quote / cancel_quote / confirm last-look — +live on the underlying `TradingClient().client` when your account is enabled for +the maker program; see the py_clob_client_v2 docs. + +## Bridge — `BridgeClient` (bridge.polymarket.com) + +`supported_assets()`, `get_quote(body)`, `create_bridge_addresses(body)`, +`create_withdrawal_addresses(body)`, `transaction_status(tx_id)`. + +## Relayer — `RelayerClient` (relayer-v2.polymarket.com) ⚠ host/paths internal + +`is_deployed(addr)`, `nonce(addr)`, `relayer_address(addr)`, +`transaction(id)`, `recent_transactions(addr)`, `submit(body)`. + +## Combo markets — `ComboClient` (combos-rfq-api.polymarket.com) + +| Capability | Method | Path | Params | +|---|---|---|---| +| Get combo markets | `combo_markets()` | GET /v1/rfq/combo-markets | limit(1-100), cursor, exclude | + +## Anything still missing + +These tools hit internal APIs that change. If a path 404s, look it up on the +relevant doc page under https://docs.polymarket.com/api-reference and either fix +the method body in `polymarket.py` or call the generic helper directly: + +```python +from polymarket import _request, CLOB, DATA +_request("GET", f"{CLOB}/some/new/path", params={"next_cursor": ""}) +``` diff --git a/references/trading.md b/references/trading.md new file mode 100644 index 0000000..37b721e --- /dev/null +++ b/references/trading.md @@ -0,0 +1,84 @@ +# Polymarket trading & authentication + +Authenticated CLOB access is two-layered. `TradingClient` (in +`scripts/polymarket.py`) wraps the official **v2** SDK (`py_clob_client_v2`), +which implements both layers correctly. Read this when an order behaves +unexpectedly or you need to choose a signature type. + +```bash +pip install py_clob_client_v2 +``` + +## L1 — wallet / EIP-712 + +Your wallet private key signs an EIP-712 message under the `ClobAuthDomain` +(name `ClobAuthDomain`, version `1`, chainId `137` Polygon). L1 is used to: +- create API credentials (`POST /auth/api-key`) +- derive existing credentials (`GET /auth/derive-api-key`) +- sign each order locally before it is posted + +The L1 request headers are `POLY_ADDRESS`, `POLY_SIGNATURE`, `POLY_TIMESTAMP`, +`POLY_NONCE`. You never send the private key anywhere — only signatures. + +## L2 — API key / HMAC + +Credential creation returns `apiKey`, `secret`, `passphrase`. Every subsequent +trading request (post/cancel/query orders, balances) is authenticated with an +HMAC-SHA256 signature over the request, sent as headers `POLY_ADDRESS`, +`POLY_SIGNATURE`, `POLY_TIMESTAMP`, `POLY_API_KEY`, `POLY_PASSPHRASE`. + +`TradingClient` calls `create_or_derive_api_key()` on construction, so the +first run derives (or creates) and caches creds in memory. To reuse fixed creds +across runs, set `CLOB_API_KEY`, `CLOB_SECRET`, `CLOB_PASSPHRASE`. + +## Signature types (how your funds are held) + +Pass `signature_type` to `TradingClient(signature_type=...)`: +- `0` — EOA: you trade directly from the signing wallet. +- `1` — Polymarket proxy (older email/magic login). Set `funder=` / + `POLY_PROXY_ADDRESS` to the proxy address that holds the USDC. +- `2` — Gnosis-safe proxy (browser-wallet signup). Also needs `funder=`. +- `3` — newer Polymarket embedded-wallet accounts (current email/Google signups). + The signer key (from "Export Private Key" in Settings → 账号 → 私钥) resolves to + a Magic EOA, while the funded **trading account** is a *separate* address shown + in Settings → 个人资料 → 地址 (even though it's labelled "for API use only"). + Under type 3 the SDK derives that account from the signer automatically, so + `get_balance_allowance()` shows the balance with no `funder` needed. + +Undocumented gotcha that wastes hours: types 0/1/2 will all silently return +`balance: 0` for these newer accounts because they derive the wrong proxy. If +auth succeeds (creds + correct signer address) but balance is 0 under 1/2, **try +`signature_type=3`**. Confirm you've got the right type when `balance` matches +your on-screen 现金 and the three exchange `allowances` are large (≈2^256-1). + +If you signed up on polymarket.com with a browser wallet, you are almost always +type `2`; older email/magic is `1`, newer email/Google is often `3`. Trading +from a raw key you funded directly +is type `0`. Getting this wrong yields "not enough balance/allowance" errors +even when funds are visible in the UI. + +## Order types + +- `GTC` — good-till-cancelled limit order (rests on the book). +- `GTD` — good-till-date; supply `expiration` (unix seconds). +- `FOK` — fill-or-kill (used for market orders by default). +- `FAK` — fill-and-kill (immediate, partial allowed, remainder cancelled). + +Prices are in USDC per share, 0–1. Size is in shares (for limit orders) or USDC +amount (for `create_market_order`). Tick size and min order size vary by market: +check `clob.tick_size(token_id)` first; orders off-tick are rejected. + +## Allowances + +Before the first trade the exchange contracts need USDC (and CTF) allowances set +on-chain. The official client exposes `update_balance_allowance()` / +`get_balance_allowance()`; the Polymarket web UI sets these automatically on +first deposit. If posting fails with an allowance error and you funded via the +website, you likely have the wrong `signature_type`/`funder` (see above) rather +than a missing allowance. + +## Safety + +These are real, hard-to-reverse financial actions. Always confirm token_id, +side, price, and size with the user before posting, and never place an order the +user did not explicitly authorize. Do not log the private key. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..70163cb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +# Public data (markets, prices, order books, positions, leaderboard) — required +requests>=2.28 + +# Authenticated trading (place/cancel/query orders) — optional, only if you trade +# pip install py-clob-client-v2 diff --git a/scripts/polymarket.py b/scripts/polymarket.py new file mode 100755 index 0000000..e73d586 --- /dev/null +++ b/scripts/polymarket.py @@ -0,0 +1,673 @@ +#!/usr/bin/env python3 +""" +Polymarket API client — covers the full public + authenticated surface. + +APIs wrapped here (see https://docs.polymarket.com/api-reference/introduction): + - Gamma (https://gamma-api.polymarket.com) — markets, events, tags, series, + comments, sports, search, public profiles. Public, no auth. + - Data (https://data-api.polymarket.com) — positions, trades, activity, + holders, value, leaderboard, open interest. Public, no auth. + - CLOB (https://clob.polymarket.com) — order book, prices, midpoints, + spreads, price history (public) + orders/trades (authenticated). + - Bridge (https://bridge.polymarket.com) — deposits/withdrawals. + - Relayer(https://relayer-v2.polymarket.com) — proxy wallet tx submission. + +Read endpoints are plain HTTP (requests). Authenticated TRADING (signing orders, +creating/deriving API keys, posting/cancelling orders, L2 HMAC headers) is +delegated to the official `py-clob-client`, which the Polymarket docs explicitly +recommend over hand-rolled EIP-712 signing. See TradingClient below. + +CLI: + python polymarket.py [--json '{...}'] [--key value ...] + python polymarket.py list-commands +""" +from __future__ import annotations + +import json +import os +import sys +from typing import Any, Optional + +try: + import requests +except ImportError: + sys.stderr.write("Missing dependency: pip install requests\n") + raise + +GAMMA = "https://gamma-api.polymarket.com" +DATA = "https://data-api.polymarket.com" +CLOB = "https://clob.polymarket.com" +BRIDGE = "https://bridge.polymarket.com" +RELAYER = "https://relayer-v2.polymarket.com" + +DEFAULT_TIMEOUT = 30 + + +class PolymarketError(RuntimeError): + pass + + +def _clean(params: Optional[dict]) -> Optional[dict]: + """Drop None values; leave False/0 intact.""" + if not params: + return None + return {k: v for k, v in params.items() if v is not None} + + +def _request(method: str, url: str, *, params=None, json_body=None, + headers=None, timeout=DEFAULT_TIMEOUT) -> Any: + resp = requests.request(method, url, params=_clean(params), json=json_body, + headers=headers, timeout=timeout) + if resp.status_code >= 400: + raise PolymarketError(f"{method} {resp.url} -> {resp.status_code}: {resp.text[:500]}") + ctype = resp.headers.get("content-type", "") + if "application/json" in ctype: + return resp.json() + return resp.text + + +# --------------------------------------------------------------------------- # +# Gamma API — markets, events, tags, series, comments, search, sports, profiles +# --------------------------------------------------------------------------- # +class GammaClient: + def __init__(self, base=GAMMA): + self.base = base + + def _get(self, path, **params): + return _request("GET", f"{self.base}{path}", params=params) + + # Markets + def list_markets(self, **params): + return self._get("/markets", **params) + + def get_market(self, market_id): + return self._get(f"/markets/{market_id}") + + def get_market_tags(self, market_id): + return self._get(f"/markets/{market_id}/tags") + + # Events + def list_events(self, **params): + return self._get("/events", **params) + + def list_events_paginated(self, **params): + return self._get("/events/pagination", **params) + + def get_event(self, event_id): + return self._get(f"/events/{event_id}") + + def get_event_by_slug(self, slug): + return self._get(f"/events/slug/{slug}") + + def get_event_tags(self, event_id): + return self._get(f"/events/{event_id}/tags") + + # Tags + def list_tags(self, **params): + return self._get("/tags", **params) + + def get_tag(self, tag_id): + return self._get(f"/tags/{tag_id}") + + def get_tag_by_slug(self, slug): + return self._get(f"/tags/slug/{slug}") + + def get_related_tags_by_id(self, tag_id): + return self._get(f"/tags/{tag_id}/related-tags") + + def get_related_tags_by_slug(self, slug): + return self._get(f"/tags/slug/{slug}/related-tags") + + def get_tags_related_to_id(self, tag_id): + return self._get(f"/tags/{tag_id}/related-tags/tags") + + def get_tags_related_to_slug(self, slug): + return self._get(f"/tags/slug/{slug}/related-tags/tags") + + # Series + def list_series(self, **params): + return self._get("/series", **params) + + def get_series(self, series_id): + return self._get(f"/series/{series_id}") + + # Comments + def list_comments(self, **params): + return self._get("/comments", **params) + + def get_comments_by_id(self, comment_id, **params): + return self._get(f"/comments/{comment_id}", **params) + + def get_comments_by_user(self, address, **params): + return self._get(f"/comments/user_address/{address}", **params) + + # Search + def search(self, q, **params): + return self._get("/public-search", q=q, **params) + + # Sports + def get_sports_metadata(self): + return self._get("/sports") + + def get_sports_market_types(self): + return self._get("/sports/market-types") + + def list_teams(self, **params): + return self._get("/teams", **params) + + # Public profiles + def get_public_profile(self, address): + return self._get(f"/public-profile/{address}") + + +# --------------------------------------------------------------------------- # +# Data API — positions, trades, activity, holders, value, leaderboard, OI +# --------------------------------------------------------------------------- # +class DataClient: + def __init__(self, base=DATA): + self.base = base + + def _get(self, path, **params): + return _request("GET", f"{self.base}{path}", params=params) + + def positions(self, user, **params): + return self._get("/positions", user=user, **params) + + def closed_positions(self, user, **params): + return self._get("/closed-positions", user=user, **params) + + def market_positions(self, market, **params): + return self._get("/positions", market=market, **params) + + def value(self, user, **params): + return self._get("/value", user=user, **params) + + def activity(self, user, **params): + return self._get("/activity", user=user, **params) + + def combo_activity(self, user, **params): + return self._get("/combo-activity", user=user, **params) + + def combo_positions(self, user, **params): + return self._get("/combo-positions", user=user, **params) + + def trades(self, **params): + return self._get("/trades", **params) + + def holders(self, market, **params): + return self._get("/holders", market=market, **params) + + def leaderboard(self, **params): + # category, timePeriod, orderBy(PNL/VOLUME), limit(≤50), offset, user, userName + return self._get("/v1/leaderboard", **params) + + def traded(self, user): + return self._get("/traded", user=user) + + def open_interest(self, **params): + return self._get("/oi", **params) + + def live_volume_event(self, event_id): + return self._get("/live-volume", id=event_id) + + # Builder analytics (public) + def builder_leaderboard(self, **params): + # timePeriod(DAY/WEEK/MONTH/ALL), limit(≤50), offset + return self._get("/v1/builders/leaderboard", **params) + + def builder_volume(self, **params): + # timePeriod(DAY/WEEK/MONTH/ALL) + return self._get("/v1/builders/volume", **params) + + def accounting_snapshot(self, user, out_path=None): + """Download the accounting snapshot ZIP (positions.csv + equity.csv). + + Returns the saved file path. ZIP is binary, so this bypasses the JSON + helper and streams to disk. + """ + url = f"{self.base}/v1/accounting/snapshot" + resp = requests.get(url, params={"user": user}, timeout=DEFAULT_TIMEOUT) + if resp.status_code >= 400: + raise PolymarketError(f"GET {resp.url} -> {resp.status_code}: {resp.text[:300]}") + out_path = out_path or f"polymarket_accounting_{user}.zip" + with open(out_path, "wb") as f: + f.write(resp.content) + return {"saved": out_path, "bytes": len(resp.content)} + + +# --------------------------------------------------------------------------- # +# Combo markets (RFQ) — separate host +# --------------------------------------------------------------------------- # +class ComboClient: + def __init__(self, base="https://combos-rfq-api.polymarket.com"): + self.base = base + + def combo_markets(self, **params): + # limit(1-100), cursor, exclude (comma-separated condition ids) + return _request("GET", f"{self.base}/v1/rfq/combo-markets", params=params) + + +# --------------------------------------------------------------------------- # +# CLOB API — PUBLIC market data (no auth) +# --------------------------------------------------------------------------- # +class ClobPublicClient: + def __init__(self, base=CLOB): + self.base = base + + def _get(self, path, **params): + return _request("GET", f"{self.base}{path}", params=params) + + def _post(self, path, body): + return _request("POST", f"{self.base}{path}", json_body=body) + + # Pricing / book + def order_book(self, token_id): + return self._get("/book", token_id=token_id) + + def order_books(self, token_ids): + return self._post("/books", [{"token_id": t} for t in token_ids]) + + def price(self, token_id, side): + return self._get("/price", token_id=token_id, side=side) + + def prices(self, params_list): + # params_list: [{"token_id":..,"side":"BUY"|"SELL"}, ...] + return self._post("/prices", params_list) + + def midpoint(self, token_id): + return self._get("/midpoint", token_id=token_id) + + def midpoints(self, token_ids): + return self._post("/midpoints", [{"token_id": t} for t in token_ids]) + + def spread(self, token_id): + return self._get("/spread", token_id=token_id) + + def spreads(self, token_ids): + return self._post("/spreads", [{"token_id": t} for t in token_ids]) + + def last_trade_price(self, token_id): + return self._get("/last-trade-price", token_id=token_id) + + def last_trade_prices(self, token_ids): + return self._post("/last-trades-prices", [{"token_id": t} for t in token_ids]) + + def tick_size(self, token_id): + return self._get("/tick-size", token_id=token_id) + + def fee_rate_bps(self, **params): + return self._get("/fee-rate-bps", **params) + + # Price history + def prices_history(self, market, **params): + # market = clob token id. interval/startTs/endTs/fidelity + return self._get("/prices-history", market=market, **params) + + # Market discovery (CLOB-side) + def get_market(self, condition_id): + return self._get(f"/markets/{condition_id}") + + def list_markets(self, next_cursor=""): + return self._get("/markets", next_cursor=next_cursor) + + def simplified_markets(self, next_cursor=""): + return self._get("/simplified-markets", next_cursor=next_cursor) + + def sampling_markets(self, next_cursor=""): + return self._get("/sampling-markets", next_cursor=next_cursor) + + def sampling_simplified_markets(self, next_cursor=""): + return self._get("/sampling-simplified-markets", next_cursor=next_cursor) + + def server_time(self): + return self._get("/time") + + # Rewards — market configs (public, no auth) + def rewards_markets_current(self, sponsored=False, next_cursor=""): + return self._get("/rewards/markets/current", sponsored=sponsored, next_cursor=next_cursor) + + def rewards_markets_multi(self, **params): + # q, tag_slug, event_id, order_by, position, min/max_volume_24hr, + # min/max_spread, min/max_price, next_cursor, page_size + return self._get("/rewards/markets/multi", **params) + + def rewards_market(self, condition_id, **params): + return self._get(f"/rewards/markets/{condition_id}", **params) + + # Builder-attributed trades (public; needs builder_code) + def builder_trades(self, builder_code, **params): + # id, market, asset_id, before, after, next_cursor + return self._get("/builder/trades", builder_code=builder_code, **params) + + +# --------------------------------------------------------------------------- # +# Bridge API — deposits / withdrawals +# --------------------------------------------------------------------------- # +class BridgeClient: + def __init__(self, base=BRIDGE): + self.base = base + + def supported_assets(self): + return _request("GET", f"{self.base}/assets") + + def get_quote(self, body): + return _request("POST", f"{self.base}/quote", json_body=body) + + def create_bridge_addresses(self, body): + return _request("POST", f"{self.base}/bridge-addresses", json_body=body) + + def create_withdrawal_addresses(self, body): + return _request("POST", f"{self.base}/withdrawal-addresses", json_body=body) + + def transaction_status(self, tx_id): + return _request("GET", f"{self.base}/transactions/{tx_id}") + + +# --------------------------------------------------------------------------- # +# Relayer API — proxy wallet helpers +# --------------------------------------------------------------------------- # +class RelayerClient: + def __init__(self, base=RELAYER): + self.base = base + + def is_deployed(self, address): + return _request("GET", f"{self.base}/deployed", params={"address": address}) + + def nonce(self, address): + return _request("GET", f"{self.base}/nonce", params={"address": address}) + + def relayer_address(self, address): + return _request("GET", f"{self.base}/relayer-address", params={"address": address}) + + def transaction(self, tx_id): + return _request("GET", f"{self.base}/transaction", params={"id": tx_id}) + + def recent_transactions(self, address): + return _request("GET", f"{self.base}/transactions", params={"address": address}) + + def submit(self, body): + return _request("POST", f"{self.base}/submit", json_body=body) + + +# --------------------------------------------------------------------------- # +# Authenticated TRADING — delegated to official py-clob-client +# --------------------------------------------------------------------------- # +# The Polymarket docs recommend the official v2 client for EIP-712 L1 signing, +# API-key derivation, and L2 HMAC headers. Reimplementing order signing by hand +# risks losing real funds, so we wrap the maintained library: +# +# pip install py_clob_client_v2 +# +# Auth model: +# L1 (wallet private key, EIP-712) -> create/derive API credentials, sign orders +# L2 (apiKey/secret/passphrase, HMAC-SHA256) -> post/cancel/query orders, and the +# user-scoped rewards/rebates GETs (implemented here via _l2_get). +# +# Env vars expected: +# PK wallet private key (0x...) +# POLY_PROXY_ADDRESS your Polymarket proxy/funder address (for proxy wallets) +# POLY_SIGNATURE_TYPE 0=EOA, 1=POLY_PROXY, 2=POLY_GNOSIS_SAFE (optional) +# CLOB_API_KEY / CLOB_SECRET / CLOB_PASSPHRASE (optional; else auto-derived) +class TradingClient: + def __init__(self, private_key=None, chain_id=137, signature_type=None, + funder=None, host=CLOB, creds=None): + try: + from py_clob_client_v2 import ClobClient, ApiCreds + except ImportError as e: + raise PolymarketError( + "Authenticated trading needs the official v2 client: " + "pip install py_clob_client_v2" + ) from e + + private_key = private_key or os.environ.get("PK") + if not private_key: + raise PolymarketError("Set PK env var (wallet private key) or pass private_key=") + funder = funder or os.environ.get("POLY_PROXY_ADDRESS") + if signature_type is None and os.environ.get("POLY_SIGNATURE_TYPE"): + signature_type = int(os.environ["POLY_SIGNATURE_TYPE"]) + + self.host = host + kwargs = dict(host=host, key=private_key, chain_id=chain_id) + if signature_type is not None: + kwargs["signature_type"] = signature_type + if funder: + kwargs["funder"] = funder + self._ApiCreds = ApiCreds + self.client = ClobClient(**kwargs) + + # Attach L2 creds: explicit -> env -> derive. + self.creds = creds or self._creds_from_env() or self.create_or_derive_api_key() + if hasattr(self.client, "set_api_creds"): + self.client.set_api_creds(self.creds) + + def _creds_from_env(self): + k = os.environ.get("CLOB_API_KEY") + s = os.environ.get("CLOB_SECRET") + p = os.environ.get("CLOB_PASSPHRASE") + if k and s and p: + return self._ApiCreds(api_key=k, api_secret=s, api_passphrase=p) + return None + + # --- API key lifecycle (L1) --- + def create_or_derive_api_key(self, nonce=None): + # Derive first: existing accounts already have creds, so trying to CREATE + # first just logs a noisy 400 ("Could not create api key"). Fall back to + # create only if derive genuinely fails (brand-new account). + try: + return self.client.derive_api_key(nonce=nonce) + except Exception: + return self.client.create_api_key(nonce=nonce) + + # --- Orders (L1 sign + L2 post) --- + def create_and_post_order(self, token_id, price, size, side, order_type="GTC"): + from py_clob_client_v2 import OrderArgs, OrderType + args = OrderArgs( + token_id=token_id, + price=float(price), + size=float(size), + side="BUY" if str(side).upper() == "BUY" else "SELL", # SDK accepts str + ) + return self.client.create_and_post_order(args, order_type=getattr(OrderType, order_type)) + + def create_and_post_market_order(self, token_id, amount, side, order_type="FOK"): + from py_clob_client_v2 import MarketOrderArgs, OrderType + args = MarketOrderArgs( + token_id=token_id, + amount=float(amount), + side="BUY" if str(side).upper() == "BUY" else "SELL", + ) + return self.client.create_and_post_market_order(args, order_type=getattr(OrderType, order_type)) + + # --- Cancel --- + def cancel_order(self, order_id): + # Accept a raw order id/hash string or an OrderPayload. + from py_clob_client_v2 import OrderPayload + payload = order_id if not isinstance(order_id, str) else OrderPayload(orderID=order_id) + return self.client.cancel_order(payload) + + def cancel_orders(self, order_hashes): + return self.client.cancel_orders(order_hashes) + + def cancel_all(self): + return self.client.cancel_all() + + def cancel_market_orders(self, payload): + return self.client.cancel_market_orders(payload) + + # --- Query (L2) --- + # These accept plain filter kwargs and build the SDK's param objects for you. + # Pass a ready-made `params` object to bypass that. Note: a freshly placed + # order can take a moment to show up here (indexing lag) even though it's live. + def get_order(self, order_id): + return self.client.get_order(order_id) + + def get_open_orders(self, market=None, asset_id=None, id=None, params=None, + only_first_page=False): + from py_clob_client_v2 import OpenOrderParams + if params is None and (market or asset_id or id): + params = OpenOrderParams(market=market, asset_id=asset_id, id=id) + return self.client.get_open_orders(params, only_first_page=only_first_page) + + def get_trades(self, market=None, asset_id=None, maker_address=None, + id=None, before=None, after=None, params=None): + from py_clob_client_v2 import TradeParams + if params is None and any([market, asset_id, maker_address, id, before, after]): + params = TradeParams(market=market, asset_id=asset_id, + maker_address=maker_address, id=id, + before=before, after=after) + return self.client.get_trades(params) + + def is_order_scoring(self, order_id=None, params=None): + from py_clob_client_v2 import OrderScoringParams + if params is None and order_id: + params = OrderScoringParams(orderId=order_id) + return self.client.is_order_scoring(params) + + def are_orders_scoring(self, order_ids=None, params=None): + from py_clob_client_v2 import OrdersScoringParams + if params is None and order_ids: + params = OrdersScoringParams(orderIds=order_ids) + return self.client.are_orders_scoring(params) + + def _balance_params(self, asset_type="COLLATERAL", token_id=None): + # COLLATERAL = your USDC; CONDITIONAL = a specific outcome token (needs token_id). + from py_clob_client_v2 import BalanceAllowanceParams, AssetType + at = AssetType.CONDITIONAL if str(asset_type).upper() == "CONDITIONAL" else AssetType.COLLATERAL + return BalanceAllowanceParams(asset_type=at, token_id=token_id) + + def get_balance_allowance(self, asset_type="COLLATERAL", token_id=None): + return self.client.get_balance_allowance(self._balance_params(asset_type, token_id)) + + def update_balance_allowance(self, asset_type="COLLATERAL", token_id=None): + return self.client.update_balance_allowance(self._balance_params(asset_type, token_id)) + + # --- L2-authenticated rewards / rebates GETs ---------------------------- # + # These read-only endpoints require L2 HMAC headers but the SDK does not + # surface them, so we sign the GET ourselves with the derived creds. This is + # the documented, deterministic L2 scheme (no fund movement, no EIP-712). + def _l2_get(self, path, params=None): + import base64, hashlib, hmac as _hmac, time as _time + from urllib.parse import urlencode + c = self.creds + api_key = getattr(c, "api_key", None) or c["api_key"] + secret = getattr(c, "api_secret", None) or c["api_secret"] + passphrase = getattr(c, "api_passphrase", None) or c["api_passphrase"] + address = self.client.get_address() if hasattr(self.client, "get_address") else os.environ.get("POLY_ADDRESS", "") + request_path = path + qs = urlencode(_clean(params) or {}) + if qs: + request_path += "?" + qs + ts = str(int(_time.time())) + # L2 HMAC: base64url(HMAC_SHA256(b64url_decode(secret), ts+method+requestPath)) + message = ts + "GET" + request_path + digest = _hmac.new(base64.urlsafe_b64decode(secret), + message.encode(), hashlib.sha256).digest() + sig = base64.urlsafe_b64encode(digest).decode() + headers = { + "POLY_ADDRESS": address, + "POLY_SIGNATURE": sig, + "POLY_TIMESTAMP": ts, + "POLY_API_KEY": api_key, + "POLY_PASSPHRASE": passphrase, + } + return _request("GET", f"{self.host}{request_path}", headers=headers) + + def rewards_user_earnings(self, date, **params): + return self._l2_get("/rewards/user", {"date": date, **params}) + + def rewards_user_total(self, date, **params): + return self._l2_get("/rewards/user/total", {"date": date, **params}) + + def rewards_user_percentages(self, **params): + return self._l2_get("/rewards/user/percentages", params) + + def rewards_user_markets(self, **params): + return self._l2_get("/rewards/user/markets", params) + + def rebates_current(self, date, maker_address, **params): + return self._l2_get("/rebates/current", + {"date": date, "maker_address": maker_address, **params}) + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # +def _parse_cli(argv): + """argv after command name -> (kwargs). Supports --key value and --json '{...}'.""" + kwargs = {} + i = 0 + while i < len(argv): + tok = argv[i] + if tok.startswith("--"): + key = tok[2:] + if key == "json": + kwargs.update(json.loads(argv[i + 1])) + i += 2 + continue + val = argv[i + 1] if i + 1 < len(argv) else "true" + # light coercion + if val.lower() in ("true", "false"): + val = val.lower() == "true" + i += 2 + kwargs[key] = val + else: + i += 1 + return kwargs + + +# command -> (client_factory, method_name) +def _registry(): + g, d, c, b, r = GammaClient(), DataClient(), ClobPublicClient(), BridgeClient(), RelayerClient() + combo = ComboClient() + reg = {} + + def add(prefix, obj): + for name in dir(obj): + if name.startswith("_"): + continue + attr = getattr(obj, name) + if callable(attr): + reg[f"{prefix}.{name}"] = attr + + add("gamma", g) + add("data", d) + add("clob", c) + add("bridge", b) + add("relayer", r) + add("combo", combo) + return reg + + +def main(argv): + if not argv or argv[0] in ("-h", "--help", "help"): + print(__doc__) + return 0 + cmd = argv[0] + reg = _registry() + + if cmd == "list-commands": + for k in sorted(reg): + print(k) + print("\nTrading (needs py-clob-client + PK env): use TradingClient in Python, e.g.") + print(" python -c 'from polymarket import TradingClient; " + "print(TradingClient().create_and_post_order(TOKEN, 0.5, 10, \"BUY\"))'") + return 0 + + if cmd.startswith("trade.") or cmd.startswith("trading."): + sys.stderr.write( + "Trading commands run in Python (they need your private key in env), " + "not via this flat CLI. See references/trading.md.\n") + return 2 + + if cmd not in reg: + sys.stderr.write(f"Unknown command: {cmd}\nTry: python polymarket.py list-commands\n") + return 2 + + kwargs = _parse_cli(argv[1:]) + result = reg[cmd](**kwargs) + print(json.dumps(result, indent=2, ensure_ascii=False) if not isinstance(result, str) else result) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))