first commit
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
# Polymarket NegRisk Reference (Polygon mainnet)
|
||||
|
||||
Single-page reference for arbitraging categorical Polymarket events where
|
||||
`Σ(best-ask of every outcome) < $1`. Covers the contracts, ABI, lifecycle, Gamma
|
||||
API detection, ID derivation, a runnable web3.py snippet, and gotchas.
|
||||
|
||||
All sources cited inline. Last verified: April 2026.
|
||||
|
||||
---
|
||||
|
||||
## 1. Concept
|
||||
|
||||
Vanilla Polymarket markets use Gnosis's **Conditional Token Framework (CTF)**:
|
||||
each binary market mints a YES and a NO ERC-1155 token, fully collateralized 1:1
|
||||
by USDC.e. Splitting 1 USDC.e gives `1 YES + 1 NO`; merging the pair returns
|
||||
1 USDC.e; after resolution the winning side redeems for 1 USDC.e each.
|
||||
|
||||
A **categorical event** ("Who wins the 2028 US Election?") is modeled as N
|
||||
independent binary markets — one per candidate. Without negRisk these markets
|
||||
are unconnected, which means a holder of `NO` on every candidate is locked up
|
||||
even though, by construction, exactly one of them must resolve YES. NegRisk
|
||||
fixes this: the **NegRiskAdapter** wraps the underlying CTF and adds a
|
||||
`convertPositions` operation: 1 NO share in market *i* of an event can be
|
||||
atomically converted into 1 YES share in **every other** market of that event.
|
||||
That makes a complete set of YES tokens (one per outcome) economically
|
||||
equivalent to $1 USDC.e and lets capital be freed early instead of waiting for
|
||||
oracle resolution. This is the property the arb strategy exploits — when the
|
||||
best-ask sum of every outcome's YES token is below $1, you can buy a complete
|
||||
set, redeem (or convert+redeem), and lock in the spread.
|
||||
([NegRisk overview](https://docs.polymarket.com/developers/neg-risk/overview),
|
||||
[neg-risk-ctf-adapter README](https://github.com/Polymarket/neg-risk-ctf-adapter),
|
||||
[ChainSecurity audit, Apr 2024](https://old.chainsecurity.com/wp-content/uploads/2024/04/ChainSecurity_Polymarket_NegRiskAdapter_audit.pdf))
|
||||
|
||||
---
|
||||
|
||||
## 2. Contract addresses (Polygon mainnet, chainId 137)
|
||||
|
||||
Source: [Polymarket Contract Addresses](https://docs.polymarket.com/resources/contract-addresses),
|
||||
cross-checked on PolygonScan.
|
||||
|
||||
| Contract | Address | PolygonScan |
|
||||
|---|---|---|
|
||||
| **NegRiskAdapter** | `0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296` | [link](https://polygonscan.com/address/0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296) |
|
||||
| **NegRiskCtfExchange** | `0xC5d563A36AE78145C45a50134d48A1215220f80a` | [link](https://polygonscan.com/address/0xc5d563a36ae78145c45a50134d48a1215220f80a) |
|
||||
| **NegRiskFeeModule** | `0x78769D50Be1763ed1CA0D5E878D93f05aabff29e` | [link](https://polygonscan.com/address/0x78769d50be1763ed1ca0d5e878d93f05aabff29e) |
|
||||
| **CTFExchange** (vanilla) | `0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E` | [link](https://polygonscan.com/address/0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e) |
|
||||
| **ConditionalTokens (CTF)** | `0x4D97DCd97eC945f40cF65F87097ACe5EA0476045` | [link](https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045) |
|
||||
| **USDC.e (collateral)** | `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174` | [link](https://polygonscan.com/address/0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174) |
|
||||
| **UmaCtfAdapter** (oracle) | `0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74` | [link](https://polygonscan.com/address/0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74) |
|
||||
| **UMA Optimistic Oracle** | `0xCB1822859cEF82Cd2Eb4E6276C7916e692995130` | [link](https://polygonscan.com/address/0xCB1822859cEF82Cd2Eb4E6276C7916e692995130) |
|
||||
|
||||
> The collateral is **USDC.e** (the bridged PoS USDC), **not** native
|
||||
> Circle-issued USDC (`0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359`). Confusing
|
||||
> these two will silently break allowance checks. See Gotchas §8.
|
||||
|
||||
---
|
||||
|
||||
## 3. Key ABI signatures
|
||||
|
||||
The NegRiskAdapter exposes both vanilla CTF-shaped overloads (so it can act as
|
||||
a drop-in `IConditionalTokens` proxy) and negRisk-specific entrypoints. Source:
|
||||
[`NegRiskAdapter.sol`](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/src/NegRiskAdapter.sol),
|
||||
[`docs/NegRiskAdapter.md`](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/docs/NegRiskAdapter.md).
|
||||
|
||||
### 3.1 Position management (call these on the NegRiskAdapter)
|
||||
|
||||
```solidity
|
||||
// Mint a complete set: deposits `_amount` USDC.e, mints `_amount` of each YES & NO.
|
||||
function splitPosition(bytes32 _conditionId, uint256 _amount) external;
|
||||
|
||||
// Burn a complete set (YES + NO of every outcome of the condition) for USDC.e.
|
||||
function mergePositions(bytes32 _conditionId, uint256 _amount) external;
|
||||
|
||||
// After UMA resolution, redeem held outcome tokens for USDC.e payout.
|
||||
// _amounts[i] is the amount of outcome-i token to burn.
|
||||
function redeemPositions(bytes32 _conditionId, uint256[] calldata _amounts) external;
|
||||
|
||||
// negRisk-specific: convert NO shares in markets selected by `_indexSet`
|
||||
// (a bitmask over the marketId's questions) into YES shares of the rest + collateral.
|
||||
function convertPositions(bytes32 _marketId, uint256 _indexSet, uint256 _amount) external;
|
||||
```
|
||||
|
||||
There are also legacy 5-arg overloads kept for `IConditionalTokens` API parity
|
||||
(unused by clients in practice):
|
||||
|
||||
```solidity
|
||||
function splitPosition(address _collateralToken, bytes32, bytes32 _conditionId,
|
||||
uint256[] calldata, uint256 _amount) external;
|
||||
function mergePositions(address _collateralToken, bytes32, bytes32 _conditionId,
|
||||
uint256[] calldata, uint256 _amount) external;
|
||||
```
|
||||
|
||||
### 3.2 ID lookups (view)
|
||||
|
||||
```solidity
|
||||
function getConditionId(bytes32 _questionId) external view returns (bytes32);
|
||||
function getPositionId(bytes32 _questionId, bool _outcome) external view returns (uint256);
|
||||
function balanceOf(address _owner, uint256 _id) external view returns (uint256);
|
||||
function balanceOfBatch(address[] memory _owners, uint256[] memory _ids)
|
||||
external view returns (uint256[] memory);
|
||||
```
|
||||
|
||||
### 3.3 Admin / oracle (you will not call these, but useful for tracing)
|
||||
|
||||
```solidity
|
||||
function prepareMarket(uint256 _feeBips, bytes calldata _metadata) external returns (bytes32);
|
||||
function prepareQuestion(bytes32 _marketId, bytes calldata _metadata) external returns (bytes32);
|
||||
function reportOutcome(bytes32 _questionId, bool _outcome) external; // onlyOperator
|
||||
```
|
||||
|
||||
### 3.4 Required ERC-20 / ERC-1155 approvals
|
||||
|
||||
Before any of the above, set:
|
||||
|
||||
```solidity
|
||||
// USDC.e:
|
||||
IERC20(USDC_E).approve(NegRiskAdapter, type(uint256).max);
|
||||
|
||||
// ERC-1155 outcome tokens (for merge / redeem / convert):
|
||||
IConditionalTokens(CTF).setApprovalForAll(NegRiskAdapter, true);
|
||||
```
|
||||
|
||||
(Source: [`NegRiskAdapter.sol`](https://raw.githubusercontent.com/Polymarket/neg-risk-ctf-adapter/main/src/NegRiskAdapter.sol))
|
||||
|
||||
---
|
||||
|
||||
## 4. End-to-end arb lifecycle
|
||||
|
||||
For a categorical event with N outcomes where `Σ best_ask_i < 1`:
|
||||
|
||||
1. **Approvals (one-time per wallet)**
|
||||
- `USDC.e.approve(NegRiskAdapter, 2^256-1)`
|
||||
- `ConditionalTokens.setApprovalForAll(NegRiskAdapter, true)`
|
||||
- Approvals required for the **NegRiskCtfExchange** (`0xC5d5...80a`) for
|
||||
trading: `USDC.e.approve(exchange, ...)` and
|
||||
`ConditionalTokens.setApprovalForAll(exchange, true)`.
|
||||
|
||||
2. **Buy a complete set via the Exchange**
|
||||
- Use the CLOB (`py-clob-client`, set `neg_risk=True` on the order options)
|
||||
to lift the best ask of each of the N outcome tokens for `size` shares.
|
||||
Total USDC.e spent ≈ `size * Σ best_ask_i`, which is < `size * $1`.
|
||||
- Equivalent: hit each `clobTokenIds[YES]` from the Gamma `markets[]` array.
|
||||
|
||||
3. **Redeem on resolution OR free capital early**
|
||||
- **Patient path**: wait for UMA to resolve the event and call
|
||||
`NegRiskAdapter.redeemPositions(conditionId_winner, [size, 0])` on the
|
||||
winning binary market. Payout = `size * 1 USDC.e`. Profit
|
||||
= `size * (1 − Σ best_ask_i)` minus gas and fees.
|
||||
- **Capital-recycling path** (the negRisk superpower): once you hold one
|
||||
YES of every outcome of the event, that bundle is economically `$1` per
|
||||
unit. Rather than redeem on each binary, you can `convertPositions` to
|
||||
consolidate, or simply burn the bundle: per the adapter, holding the
|
||||
full YES set is interchangeable with USDC.e, so a `mergePositions` on
|
||||
each conditionId (each binary has its own NO if you also hold it, or
|
||||
use `convert`) returns USDC.e instantly without waiting for the oracle.
|
||||
Practically: most arb bots redeem after resolution because acquiring a
|
||||
full NO+YES pair on every binary defeats the point — you bought only the
|
||||
YES legs for the discount.
|
||||
|
||||
4. **USDC.e arrives in your wallet.** Fees: NegRisk markets pay a small
|
||||
protocol fee on conversion (defined by `_feeBips` at `prepareMarket`
|
||||
time, paid to the Vault); redeem itself has no Polymarket fee.
|
||||
|
||||
---
|
||||
|
||||
## 5. Detecting negRisk markets via the Gamma API
|
||||
|
||||
Endpoint: `https://gamma-api.polymarket.com/events?...`
|
||||
|
||||
The two flags that matter on each `event` JSON object:
|
||||
|
||||
| JSON field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `negRisk` | bool | `true` → categorical event; outcomes are tied via the NegRiskAdapter. |
|
||||
| `negRiskMarketID` | hex string (`bytes32`) | The shared `marketId` that links every binary in this event. Same value also appears on each child `markets[i].negRiskMarketID`. |
|
||||
| `enableNegRisk` | bool | Set on a market when it can be added later as a new outcome (placeholder-capable). |
|
||||
| `negRiskAugmented` | bool | Indicates the event has been augmented with such placeholder markets. |
|
||||
|
||||
The relevant fields inside each `markets[i]` element:
|
||||
|
||||
| JSON field | Use |
|
||||
|---|---|
|
||||
| `conditionId` (`bytes32`) | Pass to `redeemPositions` / `splitPosition`. |
|
||||
| `questionID` (`bytes32`) | Source of `conditionId` via `getConditionId(questionID)`. |
|
||||
| `clobTokenIds` | `[YES_tokenId, NO_tokenId]` as decimal strings. These are the ERC-1155 ids you reference to the CLOB order book. |
|
||||
| `outcomePrices` | `["yes", "no"]` last-trade probabilities. Use `book` REST/WS for live best ask. |
|
||||
|
||||
Sample (trimmed) — `2026 FIFA World Cup Winner` event:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "12345",
|
||||
"negRisk": true,
|
||||
"negRiskMarketID": "0xb5c32a9acd39848acad4913ac4cd49c5de2afcc9d23a8a7ba2419375fab87400",
|
||||
"markets": [
|
||||
{
|
||||
"questionID": "0x...",
|
||||
"conditionId": "0x7976b8dbacf9077eb1453a62bcefd6ab2df199acd28aad276ff0d920d6992892",
|
||||
"clobTokenIds": ["4394372887385518214471608448209527405727552777602031099972143344338178308080",
|
||||
"112680630004798425069810935278212000865453267506345451433803052322987302357330"],
|
||||
"outcomePrices": ["0.1715","0.8285"],
|
||||
"negRiskMarketID": "0xb5c32a9acd39848acad4913ac4cd49c5de2afcc9d23a8a7ba2419375fab87400"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Sample query to surface candidates:
|
||||
|
||||
```python
|
||||
import requests
|
||||
events = requests.get(
|
||||
"https://gamma-api.polymarket.com/events",
|
||||
params={"closed": "false", "limit": 200, "order": "volume24hr",
|
||||
"ascending": "false"},
|
||||
timeout=15,
|
||||
).json()
|
||||
neg_risk_events = [e for e in events if e.get("negRisk")]
|
||||
for e in neg_risk_events:
|
||||
yes_asks = [float(m["outcomePrices"][0]) for m in e["markets"]]
|
||||
if sum(yes_asks) < 0.99: # candidate; verify against live book
|
||||
print(e["title"], sum(yes_asks))
|
||||
```
|
||||
|
||||
(Source: live Gamma API response; field list cross-checked against
|
||||
[`Polymarket/agents/agents/polymarket/gamma.py`](https://github.com/Polymarket/agents/blob/main/agents/polymarket/gamma.py)
|
||||
and [docs.polymarket.com/developers/neg-risk/overview](https://docs.polymarket.com/developers/neg-risk/overview).)
|
||||
|
||||
---
|
||||
|
||||
## 6. How `tokenId` and `conditionId` are derived
|
||||
|
||||
NegRisk markets reuse the underlying CTF derivation rules but plug a
|
||||
**WrappedCollateral** ERC-20 in place of raw USDC.e. That changes which
|
||||
collateral address goes into `positionId` — the one number that frequently
|
||||
trips up new integrators.
|
||||
|
||||
### 6.1 Vanilla CTF (used by non-negRisk binary markets)
|
||||
|
||||
```text
|
||||
conditionId = keccak256( oracle ‖ questionId ‖ outcomeSlotCount )
|
||||
collectionId = EC point-add of (parentCollectionId, hashToCurve(conditionId ‖ indexSet))
|
||||
positionId = uint256( keccak256( collateralToken ‖ collectionId ) )
|
||||
```
|
||||
|
||||
For a vanilla binary market: `oracle = UmaCtfAdapter`, `outcomeSlotCount = 2`,
|
||||
`collateralToken = USDC.e`, `indexSet = 1` for YES and `2` for NO.
|
||||
(Source: [CTHelpers.sol](https://raw.githubusercontent.com/Polymarket/neg-risk-ctf-adapter/main/src/libraries/CTHelpers.sol))
|
||||
|
||||
### 6.2 NegRisk markets (the difference)
|
||||
|
||||
For each outcome of a categorical event, NegRisk creates an independent
|
||||
binary CTF condition, **but** with two changes:
|
||||
|
||||
1. The CTF `oracle` field is set to the **NegRiskAdapter address**
|
||||
(`0xd91E…5296`) — not UmaCtfAdapter. The NegRiskAdapter is itself the
|
||||
thing that calls `reportPayouts` upstream.
|
||||
2. The `collateralToken` baked into `positionId` is the
|
||||
**WrappedCollateral** ERC-20 (deployed by the adapter), not USDC.e. The
|
||||
adapter holds USDC.e and mints/burns wrapper tokens 1:1 against it.
|
||||
|
||||
Practically:
|
||||
|
||||
- `marketId` (the negRisk-level grouping) =
|
||||
`keccak256(operator ‖ feeBips ‖ metadata ‖ nonce)` — assigned at
|
||||
`prepareMarket` time and is what `negRiskMarketID` in Gamma exposes.
|
||||
- `questionId` for the i-th binary in the event =
|
||||
`keccak256(marketId ‖ i)` (the index byte is the `_questionIndex`),
|
||||
which keeps all questions of a categorical event derivable from the
|
||||
single marketId.
|
||||
- `conditionId = NegRiskAdapter.getConditionId(questionId)`
|
||||
= `keccak256(NegRiskAdapter ‖ questionId ‖ 2)`.
|
||||
- `positionId(YES) = NegRiskAdapter.getPositionId(questionId, true)`
|
||||
`positionId(NO) = NegRiskAdapter.getPositionId(questionId, false)` —
|
||||
these match the decimal `clobTokenIds` returned by the Gamma API.
|
||||
|
||||
> **In practice you do not recompute these.** Pull `conditionId` and
|
||||
> `clobTokenIds` straight from Gamma; only call `getPositionId` /
|
||||
> `getConditionId` if you want to verify against on-chain truth.
|
||||
|
||||
(Source: [`NegRiskAdapter.sol`](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/src/NegRiskAdapter.sol),
|
||||
[`MarketStateLib`](https://github.com/Polymarket/neg-risk-ctf-adapter/tree/main/src/libraries),
|
||||
ChainSecurity audit §2.1)
|
||||
|
||||
---
|
||||
|
||||
## 7. End-to-end Python (web3.py) — approve + simulate redeem
|
||||
|
||||
Self-contained, structurally complete. Uses placeholder `0x...` for the
|
||||
private key only. The redeem call is built but NOT broadcast — `call()` runs
|
||||
it as an `eth_call` simulation.
|
||||
|
||||
```python
|
||||
"""
|
||||
Polymarket NegRisk arb — approval + redeem simulation on Polygon.
|
||||
Requires: web3>=6.20, requests
|
||||
"""
|
||||
import os
|
||||
import requests
|
||||
from web3 import Web3
|
||||
from web3.middleware import ExtraDataToPOAMiddleware # PoA chain (Polygon)
|
||||
|
||||
# ---- 1. Connect ----------------------------------------------------------
|
||||
RPC_URL = os.getenv("POLYGON_RPC", "https://polygon-rpc.com")
|
||||
w3 = Web3(Web3.HTTPProvider(RPC_URL))
|
||||
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
||||
assert w3.is_connected(), "RPC down"
|
||||
|
||||
# ---- 2. Addresses (Polygon mainnet) --------------------------------------
|
||||
NEG_RISK_ADAPTER = Web3.to_checksum_address("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296")
|
||||
NEG_RISK_EXCHANGE = Web3.to_checksum_address("0xC5d563A36AE78145C45a50134d48A1215220f80a")
|
||||
CTF = Web3.to_checksum_address("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045")
|
||||
USDC_E = Web3.to_checksum_address("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
|
||||
|
||||
# ---- 3. Wallet (placeholder) --------------------------------------------
|
||||
PRIVATE_KEY = os.getenv("PK", "0x" + "11" * 32) # placeholder
|
||||
acct = w3.eth.account.from_key(PRIVATE_KEY)
|
||||
ME = acct.address
|
||||
|
||||
# ---- 4. Minimal ABIs -----------------------------------------------------
|
||||
ERC20_ABI = [
|
||||
{"name":"approve","type":"function","stateMutability":"nonpayable",
|
||||
"inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],
|
||||
"outputs":[{"type":"bool"}]},
|
||||
{"name":"allowance","type":"function","stateMutability":"view",
|
||||
"inputs":[{"name":"o","type":"address"},{"name":"s","type":"address"}],
|
||||
"outputs":[{"type":"uint256"}]},
|
||||
]
|
||||
|
||||
CTF_ABI = [
|
||||
{"name":"setApprovalForAll","type":"function","stateMutability":"nonpayable",
|
||||
"inputs":[{"name":"operator","type":"address"},{"name":"approved","type":"bool"}],
|
||||
"outputs":[]},
|
||||
]
|
||||
|
||||
NEG_RISK_ADAPTER_ABI = [
|
||||
{"name":"splitPosition","type":"function","stateMutability":"nonpayable",
|
||||
"inputs":[{"name":"_conditionId","type":"bytes32"},
|
||||
{"name":"_amount","type":"uint256"}], "outputs":[]},
|
||||
{"name":"mergePositions","type":"function","stateMutability":"nonpayable",
|
||||
"inputs":[{"name":"_conditionId","type":"bytes32"},
|
||||
{"name":"_amount","type":"uint256"}], "outputs":[]},
|
||||
{"name":"redeemPositions","type":"function","stateMutability":"nonpayable",
|
||||
"inputs":[{"name":"_conditionId","type":"bytes32"},
|
||||
{"name":"_amounts","type":"uint256[]"}], "outputs":[]},
|
||||
{"name":"convertPositions","type":"function","stateMutability":"nonpayable",
|
||||
"inputs":[{"name":"_marketId","type":"bytes32"},
|
||||
{"name":"_indexSet","type":"uint256"},
|
||||
{"name":"_amount","type":"uint256"}], "outputs":[]},
|
||||
{"name":"getConditionId","type":"function","stateMutability":"view",
|
||||
"inputs":[{"name":"_questionId","type":"bytes32"}],
|
||||
"outputs":[{"type":"bytes32"}]},
|
||||
{"name":"getPositionId","type":"function","stateMutability":"view",
|
||||
"inputs":[{"name":"_questionId","type":"bytes32"},
|
||||
{"name":"_outcome","type":"bool"}],
|
||||
"outputs":[{"type":"uint256"}]},
|
||||
]
|
||||
|
||||
usdc = w3.eth.contract(address=USDC_E, abi=ERC20_ABI)
|
||||
ctf = w3.eth.contract(address=CTF, abi=CTF_ABI)
|
||||
adapter = w3.eth.contract(address=NEG_RISK_ADAPTER, abi=NEG_RISK_ADAPTER_ABI)
|
||||
|
||||
# ---- 5. Approvals (idempotent) -------------------------------------------
|
||||
MAX = 2**256 - 1
|
||||
def ensure_approvals():
|
||||
if usdc.functions.allowance(ME, NEG_RISK_ADAPTER).call() < 10**18:
|
||||
tx = usdc.functions.approve(NEG_RISK_ADAPTER, MAX).build_transaction({
|
||||
"from": ME, "nonce": w3.eth.get_transaction_count(ME),
|
||||
"maxFeePerGas": w3.to_wei(100, "gwei"),
|
||||
"maxPriorityFeePerGas": w3.to_wei(30, "gwei"),
|
||||
"chainId": 137,
|
||||
})
|
||||
# signed = acct.sign_transaction(tx); w3.eth.send_raw_transaction(signed.raw_transaction)
|
||||
print("[would broadcast] USDC.e.approve(adapter)")
|
||||
# also need 1155 approval for merge/redeem/convert legs
|
||||
print("[would broadcast] CTF.setApprovalForAll(adapter, true)")
|
||||
|
||||
ensure_approvals()
|
||||
|
||||
# ---- 6. Pull a candidate event from Gamma --------------------------------
|
||||
events = requests.get(
|
||||
"https://gamma-api.polymarket.com/events",
|
||||
params={"closed":"false","limit":50,"order":"volume24hr","ascending":"false"},
|
||||
timeout=15,
|
||||
).json()
|
||||
neg = next(e for e in events if e.get("negRisk") and e.get("markets"))
|
||||
mkt = neg["markets"][0]
|
||||
condition_id_hex = mkt["conditionId"] # 0x...
|
||||
print(f"event: {neg['title']!r} conditionId: {condition_id_hex}")
|
||||
|
||||
# ---- 7. Simulate redeem on the YES leg of one binary ---------------------
|
||||
# amounts MUST line up with outcome slot count (2 for binary): [yes_qty, no_qty]
|
||||
SIZE = 1_000_000 # 1.0 USDC.e (6 dp); placeholder until balances are real
|
||||
amounts = [SIZE, 0]
|
||||
|
||||
redeem_call = adapter.functions.redeemPositions(
|
||||
Web3.to_bytes(hexstr=condition_id_hex),
|
||||
amounts,
|
||||
)
|
||||
|
||||
# eth_call simulation (no broadcast). Will revert if the market is unresolved
|
||||
# or if you don't actually hold the tokens — both are expected for a dry run.
|
||||
try:
|
||||
sim = redeem_call.call({"from": ME})
|
||||
print("simulated redeemPositions OK; return:", sim)
|
||||
except Exception as exc:
|
||||
print("simulated redeemPositions reverted (expected for dry run):", exc)
|
||||
|
||||
# Gas estimate for a real broadcast:
|
||||
try:
|
||||
gas = redeem_call.estimate_gas({"from": ME})
|
||||
print("gas estimate:", gas)
|
||||
except Exception as exc:
|
||||
print("estimate_gas reverted (likely unresolved or no balance):", exc)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Gotchas
|
||||
|
||||
1. **USDC.e ≠ native USDC.** Polymarket exclusively uses **bridged USDC.e**
|
||||
`0x2791…84174`. Approving native Circle USDC `0x3c499…3359` will silently
|
||||
fail every order placement and adapter call. Confirm balance with
|
||||
`usdc.functions.symbol().call() == "USDC"` *and* address match.
|
||||
2. **Two distinct allowances.** You must `approve(USDC.e → NegRiskAdapter)`
|
||||
*and* `setApprovalForAll(CTF → NegRiskAdapter, true)`. The second is
|
||||
needed for `mergePositions`, `redeemPositions`, and `convertPositions`
|
||||
because the adapter pulls your ERC-1155 outcome tokens before burning.
|
||||
Trading additionally requires the same two approvals targeting the
|
||||
**NegRiskCtfExchange** address.
|
||||
3. **Gas estimates (Polygon, ~April 2026 baseline).** Approximate, varies
|
||||
±30% with calldata size:
|
||||
- `splitPosition` ~ 200–250 k gas
|
||||
- `mergePositions` ~ 200–250 k gas
|
||||
- `redeemPositions(N=2)` ~ 150–220 k gas (single binary)
|
||||
- `convertPositions` ~ 250–400 k gas (depends on `indexSet` popcount)
|
||||
At ~50 gwei `maxFeePerGas`, redeem costs roughly $0.005–$0.02 of MATIC.
|
||||
At Polygon gas spikes (>500 gwei) this can rise 10×; size the arb spread
|
||||
accordingly.
|
||||
4. **Resolution dependency on UMA.** Redeem only works after the
|
||||
UmaCtfAdapter has called `reportPayouts` upstream and (for negRisk)
|
||||
the NegRiskOperator has called `reportOutcome`. Until then
|
||||
`redeemPositions` reverts with `MarketNotResolved`/payout-vector-empty.
|
||||
UMA's optimistic oracle has a **2-hour liveness window** (default) per
|
||||
question; disputes extend by days.
|
||||
5. **No-winner / all-NO is an invalid state.** NegRisk *requires* exactly
|
||||
one question per market to resolve YES. Per the adapter docs and audit:
|
||||
if the oracle tries to report a second YES, `reportOutcome` reverts and
|
||||
the market is stuck pending manual operator action; if all questions go
|
||||
NO the system is "designed to prevent" that scenario but has no
|
||||
automatic refund path. Polymarket's stated stance after past disputes
|
||||
has been **no refunds for resolution disagreements**. Architect the
|
||||
strategy so you can hold or sell tokens before final resolution if
|
||||
ambiguity emerges.
|
||||
([NegRisk docs](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/docs/NegRiskAdapter.md),
|
||||
[Coindesk – UMA/Polymarket dispute, Mar 2025](https://www.coindesk.com/markets/2025/03/27/polymarket-uma-communities-lock-horns-after-usd7m-ukraine-bet-resolves))
|
||||
6. **Operator-only `safeTransferFrom`.** The adapter's `safeTransferFrom`
|
||||
has an `onlyAdmin` modifier. Don't try to ERC-1155-transfer wrapped
|
||||
positions through the adapter; transfer directly through the underlying
|
||||
ConditionalTokens contract.
|
||||
7. **`negRiskAugmented` events.** When `enableNegRisk` is true, new
|
||||
outcomes (questions) can be appended to a marketId after creation. Your
|
||||
"Σ asks" snapshot can become stale if a new candidate is added between
|
||||
detection and trade — re-pull the event before lifting offers.
|
||||
8. **CLOB order flag.** When placing orders against negRisk markets, you
|
||||
must pass `neg_risk=True` in the order options of `py-clob-client` so
|
||||
the order is signed for the NegRiskCtfExchange (`0xC5d5…80a`) instead
|
||||
of the vanilla CTFExchange. Wrong exchange → orders rejected.
|
||||
([NegRisk overview](https://docs.polymarket.com/developers/neg-risk/overview))
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [Polymarket Contract Addresses](https://docs.polymarket.com/resources/contract-addresses)
|
||||
- [Polymarket NegRisk Overview](https://docs.polymarket.com/developers/neg-risk/overview)
|
||||
- [Polymarket CTF Overview](https://docs.polymarket.com/developers/CTF/overview)
|
||||
- [neg-risk-ctf-adapter (repo)](https://github.com/Polymarket/neg-risk-ctf-adapter)
|
||||
- [`NegRiskAdapter.sol`](https://raw.githubusercontent.com/Polymarket/neg-risk-ctf-adapter/main/src/NegRiskAdapter.sol)
|
||||
- [`docs/NegRiskAdapter.md`](https://github.com/Polymarket/neg-risk-ctf-adapter/blob/main/docs/NegRiskAdapter.md)
|
||||
- [`CTHelpers.sol`](https://raw.githubusercontent.com/Polymarket/neg-risk-ctf-adapter/main/src/libraries/CTHelpers.sol)
|
||||
- [ctf-exchange (repo)](https://github.com/Polymarket/ctf-exchange)
|
||||
- [ChainSecurity NegRiskAdapter audit (Apr 2024)](https://old.chainsecurity.com/wp-content/uploads/2024/04/ChainSecurity_Polymarket_NegRiskAdapter_audit.pdf)
|
||||
- [Polymarket Resolution docs](https://docs.polymarket.com/concepts/resolution)
|
||||
- [Coindesk: Polymarket/UMA Ukraine bet dispute (Mar 2025)](https://www.coindesk.com/markets/2025/03/27/polymarket-uma-communities-lock-horns-after-usd7m-ukraine-bet-resolves)
|
||||
- PolygonScan verifications: [NegRiskAdapter](https://polygonscan.com/address/0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296), [NegRiskCtfExchange](https://polygonscan.com/address/0xc5d563a36ae78145c45a50134d48a1215220f80a), [ConditionalTokens](https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045), [USDC.e](https://polygonscan.com/address/0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174)
|
||||
@@ -0,0 +1,578 @@
|
||||
# Polymarket CLOB Order Signing Cookbook (`py-clob-client`)
|
||||
|
||||
A copy-pasteable reference for signing and submitting Polymarket CLOB orders from
|
||||
Python. Verified against `py-clob-client` **v0.34.6** (released 2026-02-19).
|
||||
|
||||
All citations point at `Polymarket/py-clob-client@main` on GitHub. Update the pin
|
||||
when bumping.
|
||||
|
||||
---
|
||||
|
||||
## 1. Install
|
||||
|
||||
Pin the exact version your executor was tested against. As of April 2026 the
|
||||
latest published release is **0.34.6**.
|
||||
|
||||
```bash
|
||||
pip install py-clob-client==0.34.6
|
||||
```
|
||||
|
||||
Source: <https://pypi.org/project/py-clob-client/0.34.6/> /
|
||||
[`setup.py` L7-L25](https://github.com/Polymarket/py-clob-client/blob/main/setup.py#L7-L25)
|
||||
|
||||
Transitive deps it pulls in (from `setup.py`):
|
||||
|
||||
- `eth-account>=0.13.0`
|
||||
- `eth-utils>=4.1.1`
|
||||
- `poly_eip712_structs>=0.0.1`
|
||||
- `py-order-utils>=0.3.2`
|
||||
- `py-builder-signing-sdk>=0.0.2`
|
||||
- `httpx[http2]>=0.27.0`
|
||||
- `python-dotenv`
|
||||
|
||||
Requires Python **3.9.10+**.
|
||||
|
||||
The HTTP client under the hood is `httpx` (sync). All `client.*` methods are
|
||||
**blocking**. See the `asyncio` pattern in section 7.
|
||||
|
||||
---
|
||||
|
||||
## 2. One-Time Setup: Derive L2 API Credentials
|
||||
|
||||
L2 (HMAC) creds — `api_key`, `api_secret`, `api_passphrase` — are deterministic
|
||||
for a given `(wallet, nonce)`. You generate them once and store them. The
|
||||
`create_or_derive_api_creds()` helper tries `POST /auth/api-key` first, and falls
|
||||
back to `GET /auth/derive-api-key` if the key already exists.
|
||||
|
||||
Reference:
|
||||
[`py_clob_client/client.py` L211-L260](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L211-L260)
|
||||
|
||||
```python
|
||||
# scripts/bootstrap_clob_creds.py
|
||||
"""
|
||||
Run ONCE per wallet to mint L2 API credentials, then store the three
|
||||
strings (api_key, api_secret, api_passphrase) in your secret manager.
|
||||
"""
|
||||
import os
|
||||
from py_clob_client.client import ClobClient
|
||||
from py_clob_client.constants import POLYGON
|
||||
|
||||
HOST = "https://clob.polymarket.com"
|
||||
PRIVATE_KEY = os.environ["POLY_PK"] # 0x-prefixed hex
|
||||
CHAIN_ID = POLYGON # 137 (mainnet) or AMOY=80002
|
||||
|
||||
# L1 client = host + chain + key. No creds needed yet.
|
||||
client = ClobClient(HOST, key=PRIVATE_KEY, chain_id=CHAIN_ID)
|
||||
|
||||
# Idempotent: creates if missing, derives if existing. Returns ApiCreds.
|
||||
creds = client.create_or_derive_api_creds()
|
||||
|
||||
print("CLOB_API_KEY =", creds.api_key)
|
||||
print("CLOB_SECRET =", creds.api_secret)
|
||||
print("CLOB_PASS_PHRASE =", creds.api_passphrase)
|
||||
```
|
||||
|
||||
`ApiCreds` is a dataclass with three string fields:
|
||||
[`clob_types.py` L19-L23](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L19-L23).
|
||||
|
||||
> Polymarket prints a giant warning that creds **cannot be recovered** if lost
|
||||
> — store them in your secrets backend immediately.
|
||||
> See [`constants.py` L7-L10](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/constants.py#L7-L10).
|
||||
|
||||
---
|
||||
|
||||
## 3. Client Init
|
||||
|
||||
```python
|
||||
import os
|
||||
from py_clob_client.client import ClobClient
|
||||
from py_clob_client.clob_types import ApiCreds
|
||||
from py_clob_client.constants import POLYGON
|
||||
|
||||
client = ClobClient(
|
||||
host="https://clob.polymarket.com",
|
||||
key=os.environ["POLY_PK"], # private key of the *signer* EOA
|
||||
chain_id=POLYGON, # 137
|
||||
creds=ApiCreds(
|
||||
api_key=os.environ["CLOB_API_KEY"],
|
||||
api_secret=os.environ["CLOB_SECRET"],
|
||||
api_passphrase=os.environ["CLOB_PASS_PHRASE"],
|
||||
),
|
||||
signature_type=2, # see table below
|
||||
funder="0xYourPolymarketProxyAddress", # USDC-holding address
|
||||
)
|
||||
```
|
||||
|
||||
Constructor signature:
|
||||
[`client.py` L116-L165](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L116-L165).
|
||||
|
||||
### `signature_type`
|
||||
|
||||
The integer is forwarded to `OrderBuilder.__init__` and stamped into the EIP-712
|
||||
order payload as `signatureType`
|
||||
([`order_builder/builder.py` L40-L49](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/order_builder/builder.py#L40-L49),
|
||||
[L143](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/order_builder/builder.py#L143)).
|
||||
|
||||
| `signature_type` | Wallet Model | When to use |
|
||||
| ---------------- | ------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `0` (default, `EOA`) | Plain EOA / MetaMask / hardware | Signer EOA *is* the funder. USDC and CTF tokens sit on the same address that signs. |
|
||||
| `1` (`POLY_PROXY`) | Polymarket proxy (Magic / email) | Signer EOA is the session key; funds live in a Polymarket-deployed proxy contract. |
|
||||
| `2` (`POLY_GNOSIS_SAFE`) | Gnosis Safe / browser proxy | Signer EOA is an owner; funds live in a Safe / proxy contract. |
|
||||
|
||||
Default if omitted is `EOA` (0). See `EOA` constant in
|
||||
`py_order_utils.model` re-exported via `builder.py` L4.
|
||||
|
||||
### `funder`
|
||||
|
||||
The address that **holds USDC and conditional tokens**. This goes into the
|
||||
`maker` field of the signed order
|
||||
([`builder.py` L132-L144](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/order_builder/builder.py#L132-L144)),
|
||||
while `signer` is set to the address derived from your private key.
|
||||
|
||||
- If `funder` is omitted, it defaults to `signer.address()`.
|
||||
- For arbitrage from a Polymarket UI account, `funder` = your visible Polymarket
|
||||
proxy address (look it up on polygonscan or in the UI), and `key` = the
|
||||
session/EOA key Polymarket gave you.
|
||||
- For a pure EOA setup, leave `funder=None` (or pass the same address as the
|
||||
signer) and use `signature_type=0`.
|
||||
|
||||
### Read-only mode
|
||||
|
||||
Drop `creds`, `key`, `signature_type`, `funder` for L0 (public endpoints only):
|
||||
|
||||
```python
|
||||
client = ClobClient("https://clob.polymarket.com")
|
||||
client.get_order_book(token_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Place an Order
|
||||
|
||||
### 4a. Limit order — GTC (resting)
|
||||
|
||||
Source pattern:
|
||||
[`examples/order.py` L1-L36](https://github.com/Polymarket/py-clob-client/blob/main/examples/order.py).
|
||||
|
||||
```python
|
||||
from py_clob_client.clob_types import OrderArgs, OrderType
|
||||
from py_clob_client.order_builder.constants import BUY, SELL
|
||||
|
||||
order_args = OrderArgs(
|
||||
token_id="71321045679252212594626385532706912750332728571942532289631379312455583992563",
|
||||
price=0.42, # USD per share, between 0.0 and 1.0
|
||||
size=100.0, # shares
|
||||
side=BUY, # or SELL
|
||||
)
|
||||
|
||||
signed = client.create_order(order_args) # builds EIP-712 + signs
|
||||
resp = client.post_order(signed, OrderType.GTC)
|
||||
# resp == {"success": True, "orderID": "0x...", "status": "matched"|"live"|...}
|
||||
```
|
||||
|
||||
`OrderArgs` fields (from
|
||||
[`clob_types.py` L42-L83](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L42-L83)):
|
||||
`token_id`, `price`, `size`, `side`, `fee_rate_bps=0`, `nonce=0`,
|
||||
`expiration=0`, `taker=ZERO_ADDRESS`.
|
||||
|
||||
`create_order` automatically:
|
||||
|
||||
- fetches and caches the market's `tick_size` and `neg_risk` flag
|
||||
([`client.py` L402-L448, L492-L535](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L492-L535)),
|
||||
- validates your price against the tick,
|
||||
- picks the correct exchange contract for `neg_risk` markets,
|
||||
- signs an EIP-712 order with `maker=funder`, `signer=EOA`, `signatureType=...`.
|
||||
|
||||
### 4b. Limit order — GTD (good-till-date)
|
||||
|
||||
Source: [`examples/GTD_order.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/GTD_order.py).
|
||||
|
||||
```python
|
||||
order_args = OrderArgs(
|
||||
token_id="...",
|
||||
price=0.50,
|
||||
size=100.0,
|
||||
side=BUY,
|
||||
expiration="1000000000000", # unix seconds; must be > now+60s
|
||||
)
|
||||
signed = client.create_order(order_args)
|
||||
resp = client.post_order(signed, OrderType.GTD)
|
||||
```
|
||||
|
||||
### 4c. FOK (Fill-Or-Kill)
|
||||
|
||||
FOK requires the entire size to fill immediately at the limit price or better,
|
||||
otherwise the whole order is cancelled. Polymarket uses FOK for *market* buys
|
||||
priced in dollars (`MarketOrderArgs.amount`).
|
||||
|
||||
Source: [`examples/market_buy_order.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/market_buy_order.py).
|
||||
|
||||
```python
|
||||
from py_clob_client.clob_types import MarketOrderArgs, OrderType
|
||||
|
||||
mo = MarketOrderArgs(
|
||||
token_id="...",
|
||||
amount=100.0, # BUY: USDC to spend. SELL: shares to sell.
|
||||
side=BUY,
|
||||
)
|
||||
signed = client.create_market_order(mo)
|
||||
resp = client.post_order(signed, orderType=OrderType.FOK)
|
||||
```
|
||||
|
||||
`MarketOrderArgs` defaults `order_type=OrderType.FOK`
|
||||
([`clob_types.py` L86-L122](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L86-L122)).
|
||||
|
||||
### 4d. FAK / IOC (Fill-And-Kill, a.k.a. Immediate-Or-Cancel)
|
||||
|
||||
`OrderType.FAK` is Polymarket's IOC variant — fills as much as possible
|
||||
immediately, cancels the unfilled remainder. Use this for arbitrage legs where
|
||||
partial fills are acceptable.
|
||||
|
||||
```python
|
||||
from py_clob_client.clob_types import OrderArgs, OrderType
|
||||
from py_clob_client.order_builder.constants import BUY
|
||||
|
||||
order_args = OrderArgs(token_id="...", price=0.42, size=100.0, side=BUY)
|
||||
signed = client.create_order(order_args)
|
||||
resp = client.post_order(signed, OrderType.FAK) # IOC behavior
|
||||
```
|
||||
|
||||
Enum values:
|
||||
[`clob_types.py` L11-L16`](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L11-L16):
|
||||
|
||||
```python
|
||||
class OrderType(enumerate):
|
||||
GTC = "GTC" # resting limit
|
||||
FOK = "FOK" # all-or-nothing immediate
|
||||
GTD = "GTD" # resting limit with expiry
|
||||
FAK = "FAK" # IOC: fill what you can, cancel rest
|
||||
```
|
||||
|
||||
> **TL;DR for an arbitrage executor**: use `FAK` for legs where you want IOC
|
||||
> semantics on a limit order, and `FOK` for $-denominated market-sweep buys
|
||||
> where you only want the trade if the full notional clears.
|
||||
|
||||
`post_only=True` is only legal with `GTC` / `GTD`
|
||||
([`client.py` L623-L628](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L623-L628)).
|
||||
|
||||
---
|
||||
|
||||
## 5. Cancel Orders
|
||||
|
||||
Source: [`examples/cancel_order.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/cancel_order.py),
|
||||
[`examples/cancel_orders.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/cancel_orders.py).
|
||||
|
||||
```python
|
||||
# single
|
||||
client.cancel(order_id="0xabc...")
|
||||
|
||||
# batch
|
||||
client.cancel_orders(["0xabc...", "0xdef..."])
|
||||
|
||||
# all open orders for this API key
|
||||
client.cancel_all()
|
||||
|
||||
# all orders on a market or token
|
||||
client.cancel_market_orders(market="0x...condition_id...", asset_id="")
|
||||
client.cancel_market_orders(market="", asset_id="<token_id>")
|
||||
```
|
||||
|
||||
Implementations:
|
||||
[`client.py` L663-L748](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L663-L748).
|
||||
|
||||
---
|
||||
|
||||
## 6. Get Positions / Open Orders / Fills
|
||||
|
||||
py-clob-client does **not** ship a `get_positions()` method — Polymarket exposes
|
||||
"positions" via the Data-API (separate service). Within `py-clob-client` you use
|
||||
**balance/allowance** for current token holdings, **`get_orders`** for open
|
||||
orders, and **`get_trades`** for fills.
|
||||
|
||||
### Balance / position per token
|
||||
|
||||
Source: [`examples/get_balance_allowance.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/get_balance_allowance.py).
|
||||
|
||||
```python
|
||||
from py_clob_client.clob_types import BalanceAllowanceParams, AssetType
|
||||
|
||||
# USDC balance + exchange allowance
|
||||
usdc = client.get_balance_allowance(
|
||||
BalanceAllowanceParams(asset_type=AssetType.COLLATERAL)
|
||||
)
|
||||
|
||||
# Conditional-token (outcome share) balance for one token_id
|
||||
shares = client.get_balance_allowance(
|
||||
BalanceAllowanceParams(
|
||||
asset_type=AssetType.CONDITIONAL,
|
||||
token_id="71321045679252212594626385532706912750332728571942532289631379312455583992563",
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
Note: `BalanceAllowanceParams.signature_type` defaults to `-1` and is auto-filled
|
||||
from the client.
|
||||
|
||||
### Open orders
|
||||
|
||||
```python
|
||||
from py_clob_client.clob_types import OpenOrderParams
|
||||
|
||||
orders = client.get_orders(OpenOrderParams()) # all
|
||||
orders = client.get_orders(OpenOrderParams(market="0x...condition")) # one market
|
||||
orders = client.get_orders(OpenOrderParams(asset_id="<token_id>")) # one token
|
||||
```
|
||||
|
||||
`get_orders` paginates internally with `next_cursor`
|
||||
([`client.py` L750-L769](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L750-L769)).
|
||||
|
||||
### Fills (trades)
|
||||
|
||||
Source: [`examples/get_trades.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/get_trades.py).
|
||||
|
||||
```python
|
||||
from py_clob_client.clob_types import TradeParams
|
||||
|
||||
trades = client.get_trades(
|
||||
TradeParams(
|
||||
maker_address=client.get_address(),
|
||||
market="0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1",
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Multi-Leg / Parallel Order Submission
|
||||
|
||||
`py-clob-client` is **synchronous** (it uses `httpx` in blocking mode). For
|
||||
arbitrage you have two good options:
|
||||
|
||||
### Option A — Server-side batch (preferred when atomicity matters less)
|
||||
|
||||
`post_orders` ships N orders in one HTTP round-trip. Lower latency than N
|
||||
parallel calls, but the server processes them serially.
|
||||
|
||||
Source: [`examples/orders.py`](https://github.com/Polymarket/py-clob-client/blob/main/examples/orders.py).
|
||||
|
||||
```python
|
||||
from py_clob_client.clob_types import OrderArgs, PostOrdersArgs, OrderType
|
||||
from py_clob_client.order_builder.constants import BUY, SELL
|
||||
|
||||
resp = client.post_orders([
|
||||
PostOrdersArgs(
|
||||
order=client.create_order(OrderArgs(
|
||||
token_id="...YES_TOKEN_ID...",
|
||||
price=0.50, size=100, side=BUY)),
|
||||
orderType=OrderType.FAK,
|
||||
postOnly=False,
|
||||
),
|
||||
PostOrdersArgs(
|
||||
order=client.create_order(OrderArgs(
|
||||
token_id="...NO_TOKEN_ID...",
|
||||
price=0.51, size=100, side=BUY)),
|
||||
orderType=OrderType.FAK,
|
||||
postOnly=False,
|
||||
),
|
||||
])
|
||||
```
|
||||
|
||||
Implementation: [`client.py` L592-L621](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L592-L621).
|
||||
|
||||
### Option B — `asyncio.gather` over a thread pool (true parallel HTTP)
|
||||
|
||||
When you want each leg to be a separate request fired concurrently (useful for
|
||||
hitting the matching engine at the same time across markets), wrap the sync
|
||||
client with `loop.run_in_executor`:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from py_clob_client.clob_types import OrderArgs, OrderType
|
||||
from py_clob_client.order_builder.constants import BUY
|
||||
|
||||
# One executor for the whole process is fine. Size = max parallel legs.
|
||||
_EXECUTOR = ThreadPoolExecutor(max_workers=16)
|
||||
|
||||
async def submit_leg(client, order_args: OrderArgs, order_type: OrderType):
|
||||
loop = asyncio.get_running_loop()
|
||||
# create_order signs (CPU + 1 cached HTTP call for tick/neg_risk),
|
||||
# post_order does the actual order POST.
|
||||
signed = await loop.run_in_executor(_EXECUTOR, client.create_order, order_args)
|
||||
return await loop.run_in_executor(
|
||||
_EXECUTOR, client.post_order, signed, order_type
|
||||
)
|
||||
|
||||
async def execute_arb(client, legs: list[tuple[OrderArgs, OrderType]]):
|
||||
return await asyncio.gather(
|
||||
*(submit_leg(client, args, ot) for args, ot in legs),
|
||||
return_exceptions=True, # don't let one failure cancel the others
|
||||
)
|
||||
|
||||
# Usage
|
||||
legs = [
|
||||
(OrderArgs(token_id=YES, price=0.50, size=100, side=BUY), OrderType.FAK),
|
||||
(OrderArgs(token_id=NO, price=0.51, size=100, side=BUY), OrderType.FAK),
|
||||
]
|
||||
results = asyncio.run(execute_arb(client, legs))
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The `ClobClient` instance is safe to share across threads for read paths and
|
||||
for `post_order`. Each call constructs its own `httpx` request.
|
||||
- Pre-warm tick/`neg_risk` caches by calling `client.get_tick_size(token_id)`
|
||||
and `client.get_neg_risk(token_id)` once at startup so the hot path skips two
|
||||
HTTP round trips per `create_order`. Caches live on the client
|
||||
([`client.py` L156-L160, L402-L448](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L402-L448)).
|
||||
- The tick-size cache TTL is configurable via the `tick_size_ttl` ctor arg
|
||||
(default 300s).
|
||||
|
||||
---
|
||||
|
||||
## 8. NegRisk Markets
|
||||
|
||||
NegRisk ("negative-risk") markets are Polymarket's multi-outcome markets where
|
||||
the YES tokens of all outcomes sum to ~$1. They use a **different exchange
|
||||
contract** than vanilla binary markets, but the SDK handles the routing for you.
|
||||
|
||||
### What you do NOT need to do
|
||||
|
||||
`OrderArgs` is **identical** for negRisk and vanilla tokens — you still pass
|
||||
`token_id`, `price`, `size`, `side`. There is no `neg_risk` field on
|
||||
`OrderArgs`.
|
||||
|
||||
### What the SDK does behind the scenes
|
||||
|
||||
When you call `client.create_order(...)`, it:
|
||||
|
||||
1. Calls `GET /neg-risk?token_id=...` to learn whether the token belongs to a
|
||||
negRisk market (cached forever per `token_id` —
|
||||
[`client.py` L441-L448](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L441-L448)).
|
||||
2. Calls `get_contract_config(chain_id, neg_risk=True/False)` to pick the right
|
||||
exchange address ([`builder.py` L146-L154](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/order_builder/builder.py#L146-L154)).
|
||||
3. Signs the EIP-712 payload against that exchange's domain separator.
|
||||
|
||||
### When you DO need to override
|
||||
|
||||
If you already know the market is negRisk and want to skip the lookup, pass
|
||||
`PartialCreateOrderOptions`:
|
||||
|
||||
```python
|
||||
from py_clob_client.clob_types import PartialCreateOrderOptions
|
||||
|
||||
signed = client.create_order(
|
||||
order_args,
|
||||
options=PartialCreateOrderOptions(neg_risk=True, tick_size="0.01"),
|
||||
)
|
||||
```
|
||||
|
||||
(definition:
|
||||
[`clob_types.py` L165-L172](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/clob_types.py#L165-L172).)
|
||||
|
||||
### Allowances (one-time, per signer)
|
||||
|
||||
For EOAs (`signature_type=0`) you must approve **both** the vanilla CTF Exchange
|
||||
**and** the NegRisk CTF Exchange + NegRisk Adapter on Polygon mainnet.
|
||||
Magic/proxy wallets (`signature_type=1` or `2`) have allowances set
|
||||
automatically. From the README:
|
||||
|
||||
| Token | Approve for |
|
||||
| --------------------------------------- | --------------------------------------------- |
|
||||
| USDC (`0x2791Bca1...A84174`) | `0x4bFb41d5...8B8982E` (CTF Exchange) |
|
||||
| Conditional Tokens (`0x4D97DCd9...476045`) | `0xC5d563A3...220f80a` (NegRisk Exchange) |
|
||||
| | `0xd91E80cF...0DA35296` (NegRisk Adapter) |
|
||||
|
||||
Reference allowance script (linked in the README):
|
||||
<https://gist.github.com/poly-rodr/44313920481de58d5a3f6d1f8226bd5e>
|
||||
|
||||
---
|
||||
|
||||
## 9. Error Handling
|
||||
|
||||
### Exception hierarchy
|
||||
|
||||
All client exceptions live in
|
||||
[`py_clob_client/exceptions.py`](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/exceptions.py):
|
||||
|
||||
```python
|
||||
class PolyException(Exception):
|
||||
msg: str
|
||||
|
||||
class PolyApiException(PolyException):
|
||||
status_code: int | None # HTTP status from the failed response
|
||||
error_msg: dict | str # parsed JSON or raw text body
|
||||
```
|
||||
|
||||
`PolyApiException` is raised inside `http_helpers/helpers.py` for any non-2xx
|
||||
HTTP response. `httpx` exceptions (`httpx.RequestError`,
|
||||
`httpx.TimeoutException`, `httpx.HTTPError`) can leak through on network/DNS
|
||||
failures.
|
||||
|
||||
### Recommended catch ladder
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from py_clob_client.exceptions import PolyApiException, PolyException
|
||||
|
||||
try:
|
||||
resp = client.post_order(signed, OrderType.FAK)
|
||||
except PolyApiException as e:
|
||||
# API-layer failure: invalid price, insufficient balance, market closed, 4xx/5xx
|
||||
if e.status_code in (429, 502, 503, 504):
|
||||
# rate-limited or transient — retry with backoff
|
||||
...
|
||||
elif e.status_code in (400, 422):
|
||||
# client error — DO NOT retry; surface to operator
|
||||
...
|
||||
else:
|
||||
...
|
||||
except (httpx.TimeoutException, httpx.RequestError) as e:
|
||||
# Network-layer failure — safe to retry idempotently if you used a fresh nonce
|
||||
...
|
||||
except PolyException as e:
|
||||
# Local SDK validation (e.g., invalid tick size, bad side)
|
||||
...
|
||||
```
|
||||
|
||||
### Retry guidance for an arbitrage executor
|
||||
|
||||
- **Idempotency**: Polymarket assigns the order ID on the server side from the
|
||||
EIP-712 hash, so re-posting the *exact same signed payload* is naturally
|
||||
idempotent for `GTC`/`GTD`. For `FAK`/`FOK`, the hash includes the salt so a
|
||||
fresh `create_order` call generates a *new* order — only retry if you
|
||||
confirmed via `get_orders` / `get_trades` that the original did not fill.
|
||||
- **Cap retries at 1-2** for any order-placement call; latency-sensitive
|
||||
arbitrage prefers fast failure over duplicated risk.
|
||||
- **Pre-flight checks** before any loop: `client.get_balance_allowance(...)`
|
||||
for both legs, `client.get_tick_size(token_id)` to warm the cache.
|
||||
- **Heartbeat-based dead-man switch**: `client.post_heartbeat(heartbeat_id)`
|
||||
cancels all your orders if no heartbeat arrives within 10s — useful as a
|
||||
safety net while the executor is running
|
||||
([`client.py` L713-L727](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L713-L727)).
|
||||
|
||||
### Common API-side rejection reasons
|
||||
|
||||
- `not enough balance / allowance` — top up USDC or re-run the allowance script.
|
||||
- `min size not met` — `OrderBookSummary.min_order_size`.
|
||||
- `tick size invalid` — your `price` doesn't fit the market's tick. Use
|
||||
`client.get_tick_size(token_id)` and round.
|
||||
- `market not active` — market is paused, resolved, or closed.
|
||||
- `order expired` — for `GTD`, `expiration` must be > `now + 60s`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Constants Cheat Sheet
|
||||
|
||||
```python
|
||||
from py_clob_client.constants import POLYGON, AMOY # 137, 80002
|
||||
from py_clob_client.order_builder.constants import BUY, SELL # "BUY", "SELL"
|
||||
from py_clob_client.clob_types import OrderType, AssetType
|
||||
# OrderType.GTC | FOK | GTD | FAK
|
||||
# AssetType.COLLATERAL | CONDITIONAL
|
||||
```
|
||||
|
||||
`POLYGON = 137`, `AMOY = 80002` (testnet) — see
|
||||
[`constants.py`](https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/constants.py).
|
||||
|
||||
Default CLOB host: `https://clob.polymarket.com`.
|
||||
Reference in New Issue
Block a user