pmquant 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pmq/__init__.py +31 -0
- pmq/data.py +208 -0
- pmq/exceptions.py +19 -0
- pmq/executor.py +329 -0
- pmq/mcp.py +188 -0
- pmquant-0.1.0.dist-info/METADATA +211 -0
- pmquant-0.1.0.dist-info/RECORD +10 -0
- pmquant-0.1.0.dist-info/WHEEL +4 -0
- pmquant-0.1.0.dist-info/entry_points.txt +2 -0
- pmquant-0.1.0.dist-info/licenses/LICENSE +21 -0
pmq/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""pmq: fail-closed execution and market data for Polymarket CLOB V2.
|
|
2
|
+
|
|
3
|
+
Data layer (no keys needed): get_market, parse_market, get_book,
|
|
4
|
+
best_bid_ask, band_ask_depth_usd, resolved_winner, book_inferred_winner,
|
|
5
|
+
get_tape, fee, FEE_RATES.
|
|
6
|
+
|
|
7
|
+
Execution layer (keys stay local, nothing booked without exchange
|
|
8
|
+
confirmation): PolymarketExecutor, Fill, OrderUncertain.
|
|
9
|
+
"""
|
|
10
|
+
from .data import (FEE_RATES, band_ask_depth_usd, best_bid_ask,
|
|
11
|
+
book_inferred_winner, book_meta, fee, get_book, get_market,
|
|
12
|
+
get_tape, http_get_json, parse_market, resolved_winner)
|
|
13
|
+
from .exceptions import IntrospectionMismatch, OrderUncertain, PmqError
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"FEE_RATES", "band_ask_depth_usd", "best_bid_ask", "book_inferred_winner",
|
|
18
|
+
"book_meta", "fee", "get_book", "get_market", "get_tape", "http_get_json",
|
|
19
|
+
"parse_market", "resolved_winner",
|
|
20
|
+
"PolymarketExecutor", "Fill",
|
|
21
|
+
"PmqError", "OrderUncertain", "IntrospectionMismatch",
|
|
22
|
+
"__version__",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def __getattr__(name):
|
|
27
|
+
# Lazy import: the data layer must stay usable without py-clob-client-v2.
|
|
28
|
+
if name in ("PolymarketExecutor", "Fill", "DEFAULT_BUILDER_CODE"):
|
|
29
|
+
from . import executor
|
|
30
|
+
return getattr(executor, name)
|
|
31
|
+
raise AttributeError(name)
|
pmq/data.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Market data layer for Polymarket, with the operational knowledge encoded.
|
|
2
|
+
|
|
3
|
+
Facts this module encodes (verified live, July 2026):
|
|
4
|
+
|
|
5
|
+
* The CLOB ``/book`` endpoint is REAL TIME (served by the matching engine,
|
|
6
|
+
includes ``last_trade_price``). The data-api ``/trades`` indexer LAGS
|
|
7
|
+
matching by 1 to 3 minutes (measured: freshest visible trade 120s old).
|
|
8
|
+
Therefore: books drive live decisions; the trade tape is for OFFLINE
|
|
9
|
+
scoring only, at least 5 minutes after the window closes.
|
|
10
|
+
* Gamma ``/markets?slug=`` returns ``[]`` for EXPIRED short-lived markets;
|
|
11
|
+
``/events?slug=`` still resolves them. :func:`get_market` does the
|
|
12
|
+
fallback for you.
|
|
13
|
+
* Gamma settlement (outcomePrices pinned to 0.99+) can lag the market close
|
|
14
|
+
by more than 15 minutes; the last pre-close book identifies the winner
|
|
15
|
+
immediately (a side bid pinned at 0.90+ means that side won). See
|
|
16
|
+
:func:`book_inferred_winner`.
|
|
17
|
+
* Taker fees (since 2026-03-30, decided at match time under CLOB V2):
|
|
18
|
+
``fee = rate * price * (1 - price) * shares`` with a per-category rate,
|
|
19
|
+
see :data:`FEE_RATES`. Makers always pay zero. The ``maker/taker_base_fee``
|
|
20
|
+
of 1000 bps seen in API responses is an on-chain CAP, never the charge.
|
|
21
|
+
"""
|
|
22
|
+
import calendar
|
|
23
|
+
import json
|
|
24
|
+
import time
|
|
25
|
+
import urllib.request
|
|
26
|
+
|
|
27
|
+
UA = {"User-Agent": "Mozilla/5.0"}
|
|
28
|
+
GAMMA = "https://gamma-api.polymarket.com"
|
|
29
|
+
CLOB = "https://clob.polymarket.com"
|
|
30
|
+
DATA = "https://data-api.polymarket.com"
|
|
31
|
+
|
|
32
|
+
#: Official taker fee rates per market category (docs.polymarket.com/trading/fees,
|
|
33
|
+
#: fetched 2026-07-03). Fee in $ = rate * p * (1 - p) * shares. Makers pay 0.
|
|
34
|
+
FEE_RATES = {
|
|
35
|
+
"crypto": 0.07,
|
|
36
|
+
"sports": 0.03,
|
|
37
|
+
"finance": 0.04,
|
|
38
|
+
"politics": 0.04,
|
|
39
|
+
"mentions": 0.04,
|
|
40
|
+
"tech": 0.04,
|
|
41
|
+
"economics": 0.05,
|
|
42
|
+
"culture": 0.05,
|
|
43
|
+
"weather": 0.05,
|
|
44
|
+
"geopolitics": 0.0,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fee(price, shares, rate=FEE_RATES["crypto"]):
|
|
49
|
+
"""Taker fee in $ under the current schedule. Makers pay zero.
|
|
50
|
+
|
|
51
|
+
The fee peaks at price 0.50 and vanishes toward 0 and 1: a taker fill at
|
|
52
|
+
0.95 costs about a third of one at 0.50 for the same share count.
|
|
53
|
+
"""
|
|
54
|
+
return rate * price * (1.0 - price) * shares
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def http_get_json(url, retries=3, timeout=10, logger=None):
|
|
58
|
+
"""GET a JSON document with linear backoff. Returns None on final failure."""
|
|
59
|
+
for i in range(retries):
|
|
60
|
+
try:
|
|
61
|
+
req = urllib.request.Request(url, headers=UA)
|
|
62
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
63
|
+
return json.loads(r.read().decode())
|
|
64
|
+
except Exception as e:
|
|
65
|
+
if i == retries - 1:
|
|
66
|
+
if logger:
|
|
67
|
+
logger(f"GET failed permanently: {url} ({e})")
|
|
68
|
+
return None
|
|
69
|
+
time.sleep(1.5 * (i + 1))
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_market(slug, logger=None):
|
|
74
|
+
"""Gamma market object for a slug, falling back to /events for expired ones."""
|
|
75
|
+
data = http_get_json(f"{GAMMA}/markets?slug={slug}", logger=logger)
|
|
76
|
+
if data:
|
|
77
|
+
return data[0]
|
|
78
|
+
ev = http_get_json(f"{GAMMA}/events?slug={slug}", logger=logger)
|
|
79
|
+
if ev and ev[0].get("markets"):
|
|
80
|
+
return ev[0]["markets"][0]
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _end_ts(m):
|
|
85
|
+
"""Market close time as unix epoch, or None. Gamma sends UTC ISO 8601."""
|
|
86
|
+
raw = m.get("endDate") or m.get("endDateIso") or ""
|
|
87
|
+
for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S.%fZ"):
|
|
88
|
+
try:
|
|
89
|
+
return calendar.timegm(time.strptime(raw, fmt))
|
|
90
|
+
except (ValueError, OverflowError):
|
|
91
|
+
continue
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_market(m, outcome_a=None, outcome_b=None):
|
|
96
|
+
"""Extract condition id and outcome token ids from a Gamma market object.
|
|
97
|
+
|
|
98
|
+
Works on ANY binary market: politics, sports, crypto, whatever the
|
|
99
|
+
outcome names are (Yes/No, Up/Down, team names). By default the two
|
|
100
|
+
outcomes are taken in the order the market declares them; pass
|
|
101
|
+
``outcome_a``/``outcome_b`` to pin a specific one to the ``a`` slot.
|
|
102
|
+
Returns None on any shape surprise (fail closed).
|
|
103
|
+
"""
|
|
104
|
+
if not m:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
outcomes = json.loads(m["outcomes"]) if isinstance(m.get("outcomes"), str) else m.get("outcomes")
|
|
108
|
+
token_ids = json.loads(m["clobTokenIds"]) if isinstance(m.get("clobTokenIds"), str) else m.get("clobTokenIds")
|
|
109
|
+
a = outcomes.index(outcome_a) if outcome_a else 0
|
|
110
|
+
b = outcomes.index(outcome_b) if outcome_b else (1 if a == 0 else 0)
|
|
111
|
+
return {
|
|
112
|
+
"condition_id": m.get("conditionId"),
|
|
113
|
+
"slug": m.get("slug"),
|
|
114
|
+
"token_a": token_ids[a],
|
|
115
|
+
"token_b": token_ids[b],
|
|
116
|
+
"outcome_a": outcomes[a],
|
|
117
|
+
"outcome_b": outcomes[b],
|
|
118
|
+
"outcome_prices_raw": m.get("outcomePrices"),
|
|
119
|
+
"idx_a": a,
|
|
120
|
+
"end_ts": _end_ts(m),
|
|
121
|
+
}
|
|
122
|
+
except Exception:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolved_winner(pm):
|
|
127
|
+
"""Winning outcome name from settled Gamma outcomePrices; None if unsettled."""
|
|
128
|
+
if not pm:
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
op = pm["outcome_prices_raw"]
|
|
132
|
+
op = json.loads(op) if isinstance(op, str) else op
|
|
133
|
+
op = [float(x) for x in op]
|
|
134
|
+
if max(op) < 0.99:
|
|
135
|
+
return None
|
|
136
|
+
ia = pm.get("idx_a", 0)
|
|
137
|
+
return pm["outcome_a"] if op[ia] > op[1 - ia] else pm["outcome_b"]
|
|
138
|
+
except Exception:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_book(token_id, logger=None):
|
|
143
|
+
"""Real-time CLOB book. THE live data source; never the trade tape."""
|
|
144
|
+
return http_get_json(f"{CLOB}/book?token_id={token_id}", logger=logger)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def best_bid_ask(book):
|
|
148
|
+
"""(best_bid, bid_size, best_ask, ask_size), sizes summed at the level."""
|
|
149
|
+
if not book:
|
|
150
|
+
return None, None, None, None
|
|
151
|
+
bids = book.get("bids") or []
|
|
152
|
+
asks = book.get("asks") or []
|
|
153
|
+
bb = max((float(b["price"]) for b in bids), default=None)
|
|
154
|
+
ba = min((float(a["price"]) for a in asks), default=None)
|
|
155
|
+
bb_sz = sum(float(b["size"]) for b in bids if float(b["price"]) == bb) if bb is not None else None
|
|
156
|
+
ba_sz = sum(float(a["size"]) for a in asks if float(a["price"]) == ba) if ba is not None else None
|
|
157
|
+
return bb, bb_sz, ba, ba_sz
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def book_meta(book):
|
|
161
|
+
"""Exchange metadata riding on the book response: per-market minimum
|
|
162
|
+
order size (shares), tick size, neg_risk flag, last trade price. Read
|
|
163
|
+
these from the live book instead of hardcoding exchange rules."""
|
|
164
|
+
b = book or {}
|
|
165
|
+
def _f(k):
|
|
166
|
+
try:
|
|
167
|
+
return float(b[k])
|
|
168
|
+
except (KeyError, TypeError, ValueError):
|
|
169
|
+
return None
|
|
170
|
+
return {"min_order_size": _f("min_order_size"), "tick_size": _f("tick_size"),
|
|
171
|
+
"neg_risk": b.get("neg_risk"), "last_trade_price": _f("last_trade_price")}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def band_ask_depth_usd(book, lo, hi):
|
|
175
|
+
"""Total $ notional of asks resting within [lo, hi]."""
|
|
176
|
+
asks = (book or {}).get("asks") or []
|
|
177
|
+
return round(sum(float(a["price"]) * float(a["size"]) for a in asks
|
|
178
|
+
if lo <= float(a["price"]) <= hi), 2)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def book_inferred_winner(bid_a, bid_b, threshold=0.90):
|
|
182
|
+
"""Winner from a last pre-close book snapshot; None if ambiguous.
|
|
183
|
+
|
|
184
|
+
Use when Gamma settlement lags: a side whose BID is pinned at or above
|
|
185
|
+
``threshold`` at close identifies the winner immediately.
|
|
186
|
+
"""
|
|
187
|
+
if bid_a is not None and bid_a >= threshold:
|
|
188
|
+
return "a"
|
|
189
|
+
if bid_b is not None and bid_b >= threshold:
|
|
190
|
+
return "b"
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_tape(condition_id, since_ts, max_pages=4, logger=None):
|
|
195
|
+
"""Complete trade tape for a closed market (paginated, newest first).
|
|
196
|
+
|
|
197
|
+
OFFLINE USE ONLY: the indexer lags matching by 1 to 3 minutes; call at
|
|
198
|
+
least 5 minutes after close or you will score against missing fills.
|
|
199
|
+
"""
|
|
200
|
+
out = []
|
|
201
|
+
for page in range(max_pages):
|
|
202
|
+
batch = http_get_json(
|
|
203
|
+
f"{DATA}/trades?market={condition_id}&limit=500&offset={page*500}",
|
|
204
|
+
logger=logger) or []
|
|
205
|
+
out.extend(batch)
|
|
206
|
+
if not batch or min(int(t.get("timestamp", 0)) for t in batch) < since_ts:
|
|
207
|
+
break
|
|
208
|
+
return out
|
pmq/exceptions.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""pmq exceptions. The taxonomy matters: callers must be able to tell a clean
|
|
2
|
+
rejection (order does not exist) from an unknown outcome (order MAY exist)."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PmqError(Exception):
|
|
6
|
+
"""Base class for pmq errors."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OrderUncertain(PmqError):
|
|
10
|
+
"""An order was sent but its outcome is unknown (timeout, 5xx, exception
|
|
11
|
+
after send). It MAY exist server-side. The caller must cancel, reconcile
|
|
12
|
+
against exchange truth (get_trades) and stop trading that market until
|
|
13
|
+
reconciled. Never book anything on this exception."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IntrospectionMismatch(PmqError):
|
|
17
|
+
"""The installed py-clob-client-v2 no longer matches the API surface pmq
|
|
18
|
+
was verified against. Refusing to trade is the only safe behavior: a
|
|
19
|
+
silently changed parameter meaning can turn guards into no-ops."""
|
pmq/executor.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Fail-closed execution layer for Polymarket CLOB V2.
|
|
2
|
+
|
|
3
|
+
Design contract, in one paragraph: nothing is ever booked unless the exchange
|
|
4
|
+
confirmed it. A fill exists only if the order response is a dict bearing an
|
|
5
|
+
orderID and not flagged failed, and only for the matched size the response
|
|
6
|
+
reports. A 4xx is a clean rejection (the order does not exist). A timeout or
|
|
7
|
+
5xx raises :class:`~pmq.exceptions.OrderUncertain` and the caller must
|
|
8
|
+
reconcile via :meth:`PolymarketExecutor.reconcile` before trading that market
|
|
9
|
+
again. Unparseable responses count as zero fill. This is the difference
|
|
10
|
+
between a bot that survives a flaky network and one that buys its stake again
|
|
11
|
+
on every poll.
|
|
12
|
+
|
|
13
|
+
Production notes baked in (all verified live on CLOB V2, July 2026):
|
|
14
|
+
|
|
15
|
+
* FAK/FOK BUY orders are treated by the CLOB as MARKET orders: the maker
|
|
16
|
+
amount is USDC with at most 2 decimals. Posting them as plain limit orders
|
|
17
|
+
fails with ``invalid amounts, the market buy orders maker amount supports a
|
|
18
|
+
max accuracy of 2 decimals``. pmq routes them through the market-order
|
|
19
|
+
builder, which applies the correct rounding.
|
|
20
|
+
* A 400 ``no orders found to match with FAK order`` WITH an orderID in the
|
|
21
|
+
body is a clean no-fill (the ask vanished between your poll and your send),
|
|
22
|
+
not an error state.
|
|
23
|
+
* The balance endpoint derives the funding wallet server-side from the
|
|
24
|
+
authenticated EOA and ``signature_type``; the ``funder`` parameter is never
|
|
25
|
+
sent. Funds held by a deposit wallet (ERC-1271) are only visible with
|
|
26
|
+
``signature_type=3`` (POLY_1271).
|
|
27
|
+
* Builder attribution rides INSIDE the signed order (bytes32 code); HTTP
|
|
28
|
+
headers attribute nothing on V2.
|
|
29
|
+
"""
|
|
30
|
+
import inspect
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
|
|
36
|
+
from .data import FEE_RATES, fee
|
|
37
|
+
from .exceptions import IntrospectionMismatch, OrderUncertain
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger("pmq")
|
|
40
|
+
|
|
41
|
+
HOST = "https://clob.polymarket.com"
|
|
42
|
+
CHAIN_ID = 137
|
|
43
|
+
BYTES32_RE = re.compile(r"0x[0-9a-fA-F]{64}")
|
|
44
|
+
|
|
45
|
+
# Builder attribution default. DISCLOSURE: this is the pmq maintainer's public
|
|
46
|
+
# builder code (commission 0/0: it never adds fees to your orders). It funds
|
|
47
|
+
# the project through Polymarket's builder program at zero cost to you.
|
|
48
|
+
# Opt out with PolymarketExecutor(builder_code=None) or use your own code
|
|
49
|
+
# from https://polymarket.com/settings?tab=builder
|
|
50
|
+
DEFAULT_BUILDER_CODE = "0x4b22812cf929165a247b575eb417a3b6c9e3c12e96f0159c4d0ad39f78d17371"
|
|
51
|
+
_UNSET = object()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Fill:
|
|
56
|
+
"""Exchange-confirmed execution result. ``matched_shares == 0`` means no
|
|
57
|
+
fill happened and NOTHING must be booked, whatever the caller hoped."""
|
|
58
|
+
order_id: str = ""
|
|
59
|
+
matched_shares: float = 0.0
|
|
60
|
+
matched_usd: float = 0.0
|
|
61
|
+
rejected: bool = False
|
|
62
|
+
error: str = ""
|
|
63
|
+
raw: dict = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def price(self):
|
|
67
|
+
return self.matched_usd / self.matched_shares if self.matched_shares else None
|
|
68
|
+
|
|
69
|
+
def __bool__(self):
|
|
70
|
+
return self.matched_shares > 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# (method name, parameters that must exist) verified against py-clob-client-v2
|
|
74
|
+
# 1.0.2 by introspection. If the installed client drifts, we refuse to start.
|
|
75
|
+
_EXPECTED_METHODS = {
|
|
76
|
+
"create_and_post_market_order": ("order_args", "order_type"),
|
|
77
|
+
"create_and_post_order": ("order_args", "order_type"),
|
|
78
|
+
"cancel_market_orders": ("payload",),
|
|
79
|
+
"get_open_orders": ("params",),
|
|
80
|
+
"get_trades": ("params",),
|
|
81
|
+
"get_balance_allowance": ("params",),
|
|
82
|
+
}
|
|
83
|
+
_EXPECTED_MARKET_ARGS = ("token_id", "amount", "side", "price", "builder_code")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PolymarketExecutor:
|
|
87
|
+
"""Thin, fail-closed wrapper around the official py-clob-client-v2.
|
|
88
|
+
|
|
89
|
+
Reads POLY_PRIVATE_KEY, POLY_FUNDER, POLY_SIG_TYPE and POLY_BUILDER_CODE
|
|
90
|
+
from the environment unless passed explicitly. The private key is used to
|
|
91
|
+
instantiate the signer and is never logged.
|
|
92
|
+
|
|
93
|
+
``signature_type``: 0 EOA, 1 POLY_PROXY (email/Magic), 2 POLY_GNOSIS_SAFE,
|
|
94
|
+
3 POLY_1271 (deposit wallet, the default wallet of the Polymarket app).
|
|
95
|
+
If the CLOB shows a 0 balance while the funds sit on your funder address
|
|
96
|
+
on-chain, your signature_type is wrong: only the matching type makes the
|
|
97
|
+
server derive the wallet that actually holds the pUSD.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, key=None, funder=None, signature_type=None,
|
|
101
|
+
builder_code=_UNSET, host=HOST, chain_id=CHAIN_ID,
|
|
102
|
+
client=None, derive_creds=True):
|
|
103
|
+
from py_clob_client_v2.clob_types import (
|
|
104
|
+
AssetType, BalanceAllowanceParams, BuilderConfig,
|
|
105
|
+
MarketOrderArgsV2, OpenOrderParams, OrderArgsV2,
|
|
106
|
+
OrderMarketCancelParams, OrderType, TradeParams)
|
|
107
|
+
from py_clob_client_v2.exceptions import PolyApiException
|
|
108
|
+
self._t = {
|
|
109
|
+
"AssetType": AssetType, "BalanceAllowanceParams": BalanceAllowanceParams,
|
|
110
|
+
"MarketOrderArgs": MarketOrderArgsV2, "OpenOrderParams": OpenOrderParams,
|
|
111
|
+
"OrderArgs": OrderArgsV2, "OrderMarketCancelParams": OrderMarketCancelParams,
|
|
112
|
+
"OrderType": OrderType, "TradeParams": TradeParams,
|
|
113
|
+
}
|
|
114
|
+
self.PolyApiException = PolyApiException
|
|
115
|
+
|
|
116
|
+
if builder_code is _UNSET:
|
|
117
|
+
builder_code = os.environ.get("POLY_BUILDER_CODE", DEFAULT_BUILDER_CODE)
|
|
118
|
+
if builder_code and not BYTES32_RE.fullmatch(builder_code):
|
|
119
|
+
raise ValueError("builder_code must be bytes32 hex (0x + 64 hex chars) or None")
|
|
120
|
+
self.builder_code = builder_code
|
|
121
|
+
|
|
122
|
+
if client is not None:
|
|
123
|
+
self.client = client
|
|
124
|
+
else:
|
|
125
|
+
from py_clob_client_v2.client import ClobClient
|
|
126
|
+
key = key or os.environ.get("POLY_PRIVATE_KEY")
|
|
127
|
+
if not key:
|
|
128
|
+
raise ValueError("POLY_PRIVATE_KEY missing (env or key= argument)")
|
|
129
|
+
funder = funder or os.environ.get("POLY_FUNDER")
|
|
130
|
+
if signature_type is None:
|
|
131
|
+
signature_type = int(os.environ.get("POLY_SIG_TYPE", "0"))
|
|
132
|
+
if signature_type > 0 and not funder:
|
|
133
|
+
raise ValueError("signature_type > 0 requires the funder wallet address")
|
|
134
|
+
builder_config = BuilderConfig(builder_code=builder_code) if builder_code else None
|
|
135
|
+
self.client = ClobClient(host, chain_id=chain_id, key=key,
|
|
136
|
+
signature_type=signature_type, funder=funder,
|
|
137
|
+
builder_config=builder_config,
|
|
138
|
+
use_server_time=True, retry_on_error=True)
|
|
139
|
+
if derive_creds:
|
|
140
|
+
self.client.set_api_creds(self.client.create_or_derive_api_key())
|
|
141
|
+
self._verify_client_surface()
|
|
142
|
+
log.info("executor ready (builder=%s)", "on" if builder_code else "off")
|
|
143
|
+
|
|
144
|
+
# ---------------- introspection guard ----------------
|
|
145
|
+
def _verify_client_surface(self):
|
|
146
|
+
drifts = []
|
|
147
|
+
for name, params in _EXPECTED_METHODS.items():
|
|
148
|
+
fn = getattr(self.client, name, None)
|
|
149
|
+
if fn is None:
|
|
150
|
+
drifts.append(f"method {name} missing")
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
have = set(inspect.signature(fn).parameters)
|
|
154
|
+
except (TypeError, ValueError):
|
|
155
|
+
continue
|
|
156
|
+
for p in params:
|
|
157
|
+
if p not in have:
|
|
158
|
+
drifts.append(f"{name}() lost parameter {p}")
|
|
159
|
+
margs = self._t["MarketOrderArgs"]
|
|
160
|
+
have = set(inspect.signature(margs).parameters)
|
|
161
|
+
for p in _EXPECTED_MARKET_ARGS:
|
|
162
|
+
if p not in have:
|
|
163
|
+
drifts.append(f"MarketOrderArgsV2 lost field {p}")
|
|
164
|
+
if not hasattr(self._t["OrderType"], "FAK"):
|
|
165
|
+
drifts.append("OrderType.FAK missing")
|
|
166
|
+
if drifts:
|
|
167
|
+
raise IntrospectionMismatch(
|
|
168
|
+
"installed py-clob-client-v2 drifted from the verified surface: "
|
|
169
|
+
+ "; ".join(drifts) + ". Refusing to trade; pin the version pmq "
|
|
170
|
+
"was tested with or upgrade pmq.")
|
|
171
|
+
|
|
172
|
+
# ---------------- balance ----------------
|
|
173
|
+
def collateral(self):
|
|
174
|
+
"""Available pUSD collateral as seen by the CLOB for this EOA and
|
|
175
|
+
signature_type. Raw 6-decimal units converted to $. Any parse failure
|
|
176
|
+
returns 0.0 (fail closed)."""
|
|
177
|
+
try:
|
|
178
|
+
bal = self.client.get_balance_allowance(self._t["BalanceAllowanceParams"](
|
|
179
|
+
asset_type=self._t["AssetType"].COLLATERAL))
|
|
180
|
+
return float(bal.get("balance", 0)) / 1e6 if isinstance(bal, dict) else 0.0
|
|
181
|
+
except Exception as e:
|
|
182
|
+
log.warning("collateral check failed: %s", e)
|
|
183
|
+
return 0.0
|
|
184
|
+
|
|
185
|
+
def require_collateral(self, min_usd):
|
|
186
|
+
"""Raise if the CLOB-visible collateral is below ``min_usd``."""
|
|
187
|
+
usdc = self.collateral()
|
|
188
|
+
if usdc < min_usd:
|
|
189
|
+
raise RuntimeError(
|
|
190
|
+
f"collateral {usdc:.2f} USDC below required {min_usd:.2f}. If the "
|
|
191
|
+
f"funds ARE on your funder address on-chain, your signature_type "
|
|
192
|
+
f"is wrong (deposit wallets need 3).")
|
|
193
|
+
return usdc
|
|
194
|
+
|
|
195
|
+
# ---------------- orders ----------------
|
|
196
|
+
def _parse_fill(self, resp, side):
|
|
197
|
+
if not (isinstance(resp, dict) and resp.get("orderID")
|
|
198
|
+
and resp.get("success") is not False):
|
|
199
|
+
return Fill(rejected=True, error=repr(resp)[:300],
|
|
200
|
+
raw=resp if isinstance(resp, dict) else {})
|
|
201
|
+
# For a BUY the maker amount is USDC given and the taker amount shares
|
|
202
|
+
# received; a SELL mirrors it. Unparseable counts as zero (fail closed).
|
|
203
|
+
try:
|
|
204
|
+
making = float(resp.get("makingAmount") or 0.0)
|
|
205
|
+
taking = float(resp.get("takingAmount") or 0.0)
|
|
206
|
+
except (TypeError, ValueError):
|
|
207
|
+
making = taking = 0.0
|
|
208
|
+
usd, shares = (making, taking) if side == "BUY" else (taking, making)
|
|
209
|
+
return Fill(order_id=str(resp["orderID"]), matched_shares=shares,
|
|
210
|
+
matched_usd=usd, raw=resp)
|
|
211
|
+
|
|
212
|
+
def _market_order(self, token_id, amount, side, price):
|
|
213
|
+
t = self._t
|
|
214
|
+
try:
|
|
215
|
+
resp = self.client.create_and_post_market_order(
|
|
216
|
+
t["MarketOrderArgs"](token_id=token_id, amount=amount,
|
|
217
|
+
side=side, price=price),
|
|
218
|
+
order_type=t["OrderType"].FAK)
|
|
219
|
+
except self.PolyApiException as e:
|
|
220
|
+
status = getattr(e, "status_code", None)
|
|
221
|
+
msg = str(getattr(e, "error_msg", e))[:300]
|
|
222
|
+
if status is not None and 400 <= status < 500:
|
|
223
|
+
# clean rejection, the order does not exist server-side;
|
|
224
|
+
# "no orders found to match" lands here and is a no-fill
|
|
225
|
+
return Fill(rejected=True, error=f"{status}: {msg}")
|
|
226
|
+
raise OrderUncertain(f"status={status} {msg}")
|
|
227
|
+
except Exception as e:
|
|
228
|
+
raise OrderUncertain(repr(e)[:300])
|
|
229
|
+
return self._parse_fill(resp, side)
|
|
230
|
+
|
|
231
|
+
def buy_fak(self, token_id, price_cap, usd):
|
|
232
|
+
"""Fill-and-kill market BUY: spend up to ``usd`` (rounded DOWN to the
|
|
233
|
+
cent, the exchange accuracy for market-buy maker amounts) at prices no
|
|
234
|
+
worse than ``price_cap``. Whatever does not match immediately is
|
|
235
|
+
killed by the exchange; nothing ever rests. Returns a :class:`Fill`;
|
|
236
|
+
book ONLY ``fill.matched_shares`` and ``fill.matched_usd``.
|
|
237
|
+
Raises :class:`OrderUncertain` when the outcome is unknown."""
|
|
238
|
+
usd = int(usd * 100) / 100.0
|
|
239
|
+
if usd <= 0:
|
|
240
|
+
return Fill(rejected=True, error="usd amount rounds to zero")
|
|
241
|
+
return self._market_order(token_id, usd, "BUY", price_cap)
|
|
242
|
+
|
|
243
|
+
def sell_fak(self, token_id, price_floor, shares):
|
|
244
|
+
"""Fill-and-kill market SELL of ``shares`` at prices no worse than
|
|
245
|
+
``price_floor``. Same confirmation contract as :meth:`buy_fak`.
|
|
246
|
+
The buy path has carried live volume; the sell path follows the same
|
|
247
|
+
documented semantics but flag it as less battle-tested."""
|
|
248
|
+
shares = int(shares * 100) / 100.0
|
|
249
|
+
if shares <= 0:
|
|
250
|
+
return Fill(rejected=True, error="share amount rounds to zero")
|
|
251
|
+
return self._market_order(token_id, shares, "SELL", price_floor)
|
|
252
|
+
|
|
253
|
+
def limit_gtc(self, token_id, price, size, side, post_only=False):
|
|
254
|
+
"""Resting GTC limit order. Returns a :class:`Fill` whose matched
|
|
255
|
+
amounts reflect only what crossed IMMEDIATELY; the rest is resting
|
|
256
|
+
(track it via :meth:`open_orders`, settle via :meth:`trades_totals`).
|
|
257
|
+
Maker fills cost zero fee."""
|
|
258
|
+
t = self._t
|
|
259
|
+
try:
|
|
260
|
+
resp = self.client.create_and_post_order(
|
|
261
|
+
t["OrderArgs"](token_id=token_id, price=price, size=size, side=side),
|
|
262
|
+
order_type=t["OrderType"].GTC, post_only=post_only)
|
|
263
|
+
except self.PolyApiException as e:
|
|
264
|
+
status = getattr(e, "status_code", None)
|
|
265
|
+
msg = str(getattr(e, "error_msg", e))[:300]
|
|
266
|
+
if status is not None and 400 <= status < 500:
|
|
267
|
+
return Fill(rejected=True, error=f"{status}: {msg}")
|
|
268
|
+
raise OrderUncertain(f"status={status} {msg}")
|
|
269
|
+
except Exception as e:
|
|
270
|
+
raise OrderUncertain(repr(e)[:300])
|
|
271
|
+
return self._parse_fill(resp, side)
|
|
272
|
+
|
|
273
|
+
# ---------------- reconciliation ----------------
|
|
274
|
+
def cancel_market(self, condition_id):
|
|
275
|
+
"""Cancel every resting order of ours on one market. Never raises."""
|
|
276
|
+
try:
|
|
277
|
+
self.client.cancel_market_orders(
|
|
278
|
+
self._t["OrderMarketCancelParams"](market=condition_id))
|
|
279
|
+
return True
|
|
280
|
+
except Exception as e:
|
|
281
|
+
log.warning("cancel_market(%s) failed: %s", condition_id, e)
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
def open_orders(self, condition_id=None):
|
|
285
|
+
try:
|
|
286
|
+
return self.client.get_open_orders(
|
|
287
|
+
self._t["OpenOrderParams"](market=condition_id)) or []
|
|
288
|
+
except Exception as e:
|
|
289
|
+
log.warning("get_open_orders failed: %s", e)
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
def trades_totals(self, condition_id, token_id=None, side="BUY",
|
|
293
|
+
fee_rate=FEE_RATES["crypto"]):
|
|
294
|
+
"""Exchange truth for one market: (shares, usd, fee_estimate) actually
|
|
295
|
+
traded on our account, or None if the API is unreachable. FAILED
|
|
296
|
+
trades are excluded; maker fills carry zero fee."""
|
|
297
|
+
try:
|
|
298
|
+
trades = self.client.get_trades(
|
|
299
|
+
self._t["TradeParams"](market=condition_id, asset_id=token_id))
|
|
300
|
+
except Exception as e:
|
|
301
|
+
log.warning("get_trades(%s) failed: %s", condition_id, e)
|
|
302
|
+
return None
|
|
303
|
+
sh = usd = fees = 0.0
|
|
304
|
+
for t in trades or []:
|
|
305
|
+
if not isinstance(t, dict) or t.get("side") != side:
|
|
306
|
+
continue
|
|
307
|
+
if t.get("status") == "FAILED":
|
|
308
|
+
continue
|
|
309
|
+
try:
|
|
310
|
+
s, p = float(t.get("size") or 0), float(t.get("price") or 0)
|
|
311
|
+
except (TypeError, ValueError):
|
|
312
|
+
continue
|
|
313
|
+
sh += s
|
|
314
|
+
usd += p * s
|
|
315
|
+
if t.get("trader_side") != "MAKER":
|
|
316
|
+
fees += fee(p, s, fee_rate)
|
|
317
|
+
return sh, usd, fees
|
|
318
|
+
|
|
319
|
+
def reconcile(self, condition_id, token_id=None):
|
|
320
|
+
"""After :class:`OrderUncertain`: cancel anything possibly resting,
|
|
321
|
+
verify nothing stayed open, then return exchange-truth totals. Call
|
|
322
|
+
this BEFORE placing any new order on that market."""
|
|
323
|
+
self.cancel_market(condition_id)
|
|
324
|
+
still = self.open_orders(condition_id)
|
|
325
|
+
if still:
|
|
326
|
+
log.warning("reconcile(%s): %d orders still open after cancel, retrying",
|
|
327
|
+
condition_id, len(still))
|
|
328
|
+
self.cancel_market(condition_id)
|
|
329
|
+
return self.trades_totals(condition_id, token_id)
|
pmq/mcp.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""pmq-mcp: MCP server exposing pmq to LLM agents, safety rails included.
|
|
2
|
+
|
|
3
|
+
Run with ``pmq-mcp`` (stdio transport). Design rules:
|
|
4
|
+
|
|
5
|
+
* Read tools work with no credentials and never touch keys.
|
|
6
|
+
* Trading tools exist ONLY when the operator sets ``PMQ_MCP_LIVE=1`` in the
|
|
7
|
+
server's environment. An agent cannot talk its way past a tool that was
|
|
8
|
+
never registered.
|
|
9
|
+
* Every order is capped by ``PMQ_MCP_MAX_USD`` (default 10) per call.
|
|
10
|
+
* On an unknown outcome (timeout, 5xx) the server reconciles automatically
|
|
11
|
+
and reports exchange truth instead of guessing.
|
|
12
|
+
* POLY_PRIVATE_KEY is read by the executor, used to sign, never returned.
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
import urllib.parse
|
|
16
|
+
|
|
17
|
+
from . import data
|
|
18
|
+
from .exceptions import OrderUncertain
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from mcp.server.fastmcp import FastMCP
|
|
22
|
+
except ImportError as e: # pragma: no cover
|
|
23
|
+
raise SystemExit("pmq-mcp needs the optional dependency: pip install 'pmq[mcp]'") from e
|
|
24
|
+
|
|
25
|
+
LIVE_ENABLED = os.environ.get("PMQ_MCP_LIVE") == "1"
|
|
26
|
+
MAX_USD = float(os.environ.get("PMQ_MCP_MAX_USD", "10"))
|
|
27
|
+
|
|
28
|
+
mcp = FastMCP(
|
|
29
|
+
"pmq",
|
|
30
|
+
instructions=(
|
|
31
|
+
"Polymarket CLOB V2 data and fail-closed execution. Books are real "
|
|
32
|
+
"time; the trade tape lags 1 to 3 minutes, never trade off it. "
|
|
33
|
+
+ ("Trading tools are ENABLED; every order is capped at "
|
|
34
|
+
f"{MAX_USD:.2f} USD per call." if LIVE_ENABLED else
|
|
35
|
+
"Trading tools are DISABLED (operator did not set PMQ_MCP_LIVE=1); "
|
|
36
|
+
"this server is read-only.")),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_executor = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ex():
|
|
43
|
+
global _executor
|
|
44
|
+
if _executor is None:
|
|
45
|
+
from .executor import PolymarketExecutor
|
|
46
|
+
_executor = PolymarketExecutor()
|
|
47
|
+
return _executor
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fill_dict(fill):
|
|
51
|
+
return {"order_id": fill.order_id, "matched_shares": fill.matched_shares,
|
|
52
|
+
"matched_usd": fill.matched_usd, "price": fill.price,
|
|
53
|
+
"rejected": fill.rejected, "error": fill.error,
|
|
54
|
+
"booked": bool(fill)}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@mcp.tool()
|
|
58
|
+
def find_markets(query: str = "", limit: int = 10) -> list:
|
|
59
|
+
"""Discover tradeable Polymarket markets of ANY kind (politics, sports,
|
|
60
|
+
crypto, culture). With a query, full-text search; without, the most
|
|
61
|
+
active events by 24h volume. Returns event title plus, per market, the
|
|
62
|
+
slug to pass to the `market` tool and the outcome names."""
|
|
63
|
+
if query:
|
|
64
|
+
res = data.http_get_json(
|
|
65
|
+
f"{data.GAMMA}/public-search?q={urllib.parse.quote(query)}"
|
|
66
|
+
f"&limit_per_type={min(limit, 20)}") or {}
|
|
67
|
+
events = res.get("events") or []
|
|
68
|
+
else:
|
|
69
|
+
events = data.http_get_json(
|
|
70
|
+
f"{data.GAMMA}/events?closed=false&order=volume24hr&ascending=false"
|
|
71
|
+
f"&limit={min(limit, 20)}") or []
|
|
72
|
+
out = []
|
|
73
|
+
for ev in events[:limit]:
|
|
74
|
+
for m in (ev.get("markets") or [])[:4]:
|
|
75
|
+
pm = data.parse_market(m)
|
|
76
|
+
if pm:
|
|
77
|
+
out.append({"event": ev.get("title"), "market_slug": pm["slug"],
|
|
78
|
+
"outcomes": [pm["outcome_a"], pm["outcome_b"]],
|
|
79
|
+
"volume24hr": ev.get("volume24hr")})
|
|
80
|
+
return out or [{"error": "nothing found"}]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@mcp.tool()
|
|
84
|
+
def market(slug: str) -> dict:
|
|
85
|
+
"""Resolve one Polymarket market by its gamma slug (any category, works
|
|
86
|
+
for expired short-lived markets too). Returns condition_id, the outcome
|
|
87
|
+
names mapped to their token ids (use those token ids with `book` and the
|
|
88
|
+
trading tools), the close time and the settled winner if resolution
|
|
89
|
+
already happened."""
|
|
90
|
+
pm = data.parse_market(data.get_market(slug))
|
|
91
|
+
if not pm:
|
|
92
|
+
return {"error": f"no market found for slug {slug!r}"}
|
|
93
|
+
return {"condition_id": pm["condition_id"],
|
|
94
|
+
"outcomes": {pm["outcome_a"]: pm["token_a"], pm["outcome_b"]: pm["token_b"]},
|
|
95
|
+
"end_ts": pm["end_ts"],
|
|
96
|
+
"settled_winner": data.resolved_winner(pm)}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@mcp.tool()
|
|
100
|
+
def book(token_id: str, depth_lo: float = 0.0, depth_hi: float = 1.0) -> dict:
|
|
101
|
+
"""REAL-TIME order book summary for one outcome token: best bid/ask with
|
|
102
|
+
sizes, plus the $ notional of asks resting inside [depth_lo, depth_hi].
|
|
103
|
+
This endpoint is served by the matching engine; trust it over the trade
|
|
104
|
+
tape for any live decision."""
|
|
105
|
+
b = data.get_book(token_id)
|
|
106
|
+
if not b:
|
|
107
|
+
return {"error": "book unavailable"}
|
|
108
|
+
bid, bid_sz, ask, ask_sz = data.best_bid_ask(b)
|
|
109
|
+
return {"bid": bid, "bid_size": bid_sz, "ask": ask, "ask_size": ask_sz,
|
|
110
|
+
"ask_depth_usd_in_range": data.band_ask_depth_usd(b, depth_lo, depth_hi),
|
|
111
|
+
**data.book_meta(b)}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@mcp.tool()
|
|
115
|
+
def taker_fee(price: float, shares: float, category: str = "crypto") -> dict:
|
|
116
|
+
"""Official Polymarket taker fee in $ (fee = rate * p * (1-p) * shares).
|
|
117
|
+
Categories and rates: crypto 0.07, sports 0.03, finance/politics/mentions/
|
|
118
|
+
tech 0.04, economics/culture/weather 0.05, geopolitics 0. Makers pay 0."""
|
|
119
|
+
rate = data.FEE_RATES.get(category)
|
|
120
|
+
if rate is None:
|
|
121
|
+
return {"error": f"unknown category, pick one of {sorted(data.FEE_RATES)}"}
|
|
122
|
+
return {"fee_usd": data.fee(price, shares, rate), "rate": rate,
|
|
123
|
+
"cost_per_share_incl_fee": price + data.fee(price, 1.0, rate)}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@mcp.tool()
|
|
127
|
+
def account_collateral() -> dict:
|
|
128
|
+
"""Collateral (pUSD, $) the CLOB sees for the configured account. If this
|
|
129
|
+
is 0 while funds are on-chain, the operator's POLY_SIG_TYPE is wrong
|
|
130
|
+
(the Polymarket app's deposit wallet needs 3)."""
|
|
131
|
+
return {"collateral_usd": _ex().collateral()}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@mcp.tool()
|
|
135
|
+
def account_trades(condition_id: str, token_id: str = "") -> dict:
|
|
136
|
+
"""Exchange-truth totals of OUR account's BUY trades on one market:
|
|
137
|
+
(shares, usd, fee estimate). This is the reconciliation source, use it
|
|
138
|
+
after any uncertainty instead of trusting local bookkeeping."""
|
|
139
|
+
totals = _ex().trades_totals(condition_id, token_id or None)
|
|
140
|
+
if totals is None:
|
|
141
|
+
return {"error": "trades endpoint unreachable"}
|
|
142
|
+
sh, usd, fees = totals
|
|
143
|
+
return {"shares": sh, "usd": usd, "fee_estimate": fees}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if LIVE_ENABLED:
|
|
147
|
+
@mcp.tool()
|
|
148
|
+
def fak_buy(token_id: str, price_cap: float, usd: float) -> dict:
|
|
149
|
+
"""Place a fill-and-kill market BUY: spend up to `usd` at prices no
|
|
150
|
+
worse than `price_cap`. Nothing rests on the book. Book ONLY what
|
|
151
|
+
`matched_shares`/`matched_usd` report; `booked: false` means nothing
|
|
152
|
+
happened. Hard-capped per call by the operator's PMQ_MCP_MAX_USD."""
|
|
153
|
+
if usd > MAX_USD:
|
|
154
|
+
return {"error": f"refused: {usd} exceeds the {MAX_USD} USD per-order cap"}
|
|
155
|
+
try:
|
|
156
|
+
return _fill_dict(_ex().buy_fak(token_id, price_cap, usd))
|
|
157
|
+
except OrderUncertain as e:
|
|
158
|
+
return {"error": f"outcome unknown ({e}); call cancel_and_reconcile "
|
|
159
|
+
"on this market before any new order"}
|
|
160
|
+
|
|
161
|
+
@mcp.tool()
|
|
162
|
+
def fak_sell(token_id: str, price_floor: float, shares: float) -> dict:
|
|
163
|
+
"""Fill-and-kill market SELL of `shares` at prices no worse than
|
|
164
|
+
`price_floor`. Same confirmation contract as fak_buy."""
|
|
165
|
+
try:
|
|
166
|
+
return _fill_dict(_ex().sell_fak(token_id, price_floor, shares))
|
|
167
|
+
except OrderUncertain as e:
|
|
168
|
+
return {"error": f"outcome unknown ({e}); "
|
|
169
|
+
"call account_trades before any new order"}
|
|
170
|
+
|
|
171
|
+
@mcp.tool()
|
|
172
|
+
def cancel_and_reconcile(condition_id: str, token_id: str = "") -> dict:
|
|
173
|
+
"""Cancel every resting order of ours on one market, verify none
|
|
174
|
+
stayed open, and return exchange-truth totals. Call this after any
|
|
175
|
+
'outcome unknown' before placing new orders on that market."""
|
|
176
|
+
totals = _ex().reconcile(condition_id, token_id or None)
|
|
177
|
+
if totals is None:
|
|
178
|
+
return {"cancelled": True, "error": "trades endpoint unreachable"}
|
|
179
|
+
sh, usd, fees = totals
|
|
180
|
+
return {"cancelled": True, "shares": sh, "usd": usd, "fee_estimate": fees}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def main():
|
|
184
|
+
mcp.run()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
main()
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pmquant
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fail-closed execution and market-data layer for Polymarket CLOB V2: local signing, confirmed fills only, fee-correct math, working deposit-wallet (POLY_1271) support.
|
|
5
|
+
Project-URL: Homepage, https://github.com/crp4222/pmq
|
|
6
|
+
Project-URL: Issues, https://github.com/crp4222/pmq/issues
|
|
7
|
+
Author: crp4222
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: builder-code,clob,clob-v2,deposit-wallet,execution,fak,market-order,orderbook,poly-1271,polymarket,prediction-markets,quant,trading
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: py-clob-client-v2<2,>=1.0.2
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: mcp>=1.2; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
|
+
Provides-Extra: mcp
|
|
23
|
+
Requires-Dist: mcp>=1.2; extra == 'mcp'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# pmq
|
|
27
|
+
|
|
28
|
+
Fail-closed execution and market data for **Polymarket CLOB V2**, in Python.
|
|
29
|
+
Local signing (your keys never leave your process), exchange-confirmed fills
|
|
30
|
+
only, fee-correct math, and deposit-wallet (`POLY_1271`) support that actually
|
|
31
|
+
works in production.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install pmquant # distribution name pmquant, import name pmq
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
(PyPI's similarity check reserves the bare name; the module you import is
|
|
38
|
+
`pmq`, same pattern as beautifulsoup4/bs4.)
|
|
39
|
+
|
|
40
|
+
As of 2026-07-03 this is, to our knowledge, the **only maintained Python
|
|
41
|
+
layer combining local CLOB V2 signing, an exchange-confirmed fill contract,
|
|
42
|
+
and working deposit-wallet (POLY_1271) auth**. That claim is dated and
|
|
43
|
+
falsifiable: the comparison table below names the alternatives and what each
|
|
44
|
+
does instead; open an issue if it goes stale.
|
|
45
|
+
|
|
46
|
+
## Why this exists
|
|
47
|
+
|
|
48
|
+
Polymarket cut over to CLOB V2 on 2026-04-28. V1-signed orders are rejected in
|
|
49
|
+
production, the fee schedule is decided at match time, and the official client
|
|
50
|
+
examples leave several traps undocumented. Every line of pmq was paid for with
|
|
51
|
+
a real error in live trading:
|
|
52
|
+
|
|
53
|
+
* `invalid amounts, the market buy orders maker amount supports a max accuracy
|
|
54
|
+
of 2 decimals`: the CLOB treats FAK/FOK buys as **market orders**. pmq routes
|
|
55
|
+
them through the market-order builder with the correct rounding.
|
|
56
|
+
* `no orders found to match with FAK order` (HTTP 400, yet with an `orderID`):
|
|
57
|
+
a clean no-fill, not an error. pmq returns an empty `Fill` instead of crashing
|
|
58
|
+
or, worse, retrying blindly.
|
|
59
|
+
* CLOB shows `balance: 0` while your pUSD sits on-chain: the balance endpoint
|
|
60
|
+
ignores your `funder` parameter and derives the wallet from your EOA and
|
|
61
|
+
`signature_type`. Funds in the Polymarket app's default wallet (an ERC-1271
|
|
62
|
+
deposit wallet) are only visible with `signature_type=3`.
|
|
63
|
+
|
|
64
|
+
The full write-up with reproduction details: [docs/war-story.md](docs/war-story.md).
|
|
65
|
+
|
|
66
|
+
## The contract: nothing is booked without exchange confirmation
|
|
67
|
+
|
|
68
|
+
| Situation | What pmq does |
|
|
69
|
+
|---|---|
|
|
70
|
+
| Response is a dict with `orderID`, not flagged failed | `Fill` with the **matched** size read from the response |
|
|
71
|
+
| Error dict on HTTP 200, string body, `success: false` | `Fill(rejected=True)`, zero booked |
|
|
72
|
+
| HTTP 4xx (incl. FAK no-match) | `Fill(rejected=True)`, zero booked |
|
|
73
|
+
| Timeout, 5xx, exception after send | raises `OrderUncertain`: the order MAY exist. Call `reconcile()` before trading that market again |
|
|
74
|
+
| Unparseable matched amounts | zero booked (fail closed) |
|
|
75
|
+
|
|
76
|
+
`reconcile(condition_id)` cancels anything resting, verifies nothing stayed
|
|
77
|
+
open, and returns `(shares, usd, fees)` from `get_trades`: the exchange truth,
|
|
78
|
+
not your hopes.
|
|
79
|
+
|
|
80
|
+
At startup pmq **introspects the installed py-clob-client-v2** against the API
|
|
81
|
+
surface it was verified on, and refuses to trade on drift instead of sending
|
|
82
|
+
orders through changed semantics.
|
|
83
|
+
|
|
84
|
+
## Quickstart
|
|
85
|
+
|
|
86
|
+
Market data needs no keys:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import pmq
|
|
90
|
+
|
|
91
|
+
m = pmq.parse_market(pmq.get_market("btc-updown-15m-1783062000"))
|
|
92
|
+
book = pmq.get_book(m["token_a"])
|
|
93
|
+
bid, bid_sz, ask, ask_sz = pmq.best_bid_ask(book)
|
|
94
|
+
print(ask, pmq.band_ask_depth_usd(book, 0.90, 0.97))
|
|
95
|
+
print(pmq.fee(price=0.95, shares=100)) # taker fee in $, crypto rate
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Execution (reads `POLY_PRIVATE_KEY`, `POLY_FUNDER`, `POLY_SIG_TYPE` from the
|
|
99
|
+
environment):
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from pmq import PolymarketExecutor, OrderUncertain
|
|
103
|
+
|
|
104
|
+
ex = PolymarketExecutor() # signature_type=3 for the app's deposit wallet
|
|
105
|
+
ex.require_collateral(5.0) # fail fast, with a diagnostic that names sig_type
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
fill = ex.buy_fak(token_id=m["token_a"], price_cap=0.95, usd=5.00)
|
|
109
|
+
except OrderUncertain:
|
|
110
|
+
ex.reconcile(m["condition_id"], m["token_a"]) # exchange truth before anything else
|
|
111
|
+
else:
|
|
112
|
+
if fill: # book ONLY what matched
|
|
113
|
+
print(fill.matched_shares, "shares at", fill.price, "order", fill.order_id)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`sell_fak` and `limit_gtc` follow the same contract. The buy path has carried
|
|
117
|
+
live volume; treat the sell path as following the same documented semantics
|
|
118
|
+
with less battle time.
|
|
119
|
+
|
|
120
|
+
## The signature_type table nobody gives you
|
|
121
|
+
|
|
122
|
+
| `signature_type` | Wallet | When it is yours |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| 0 | the EOA itself | you trade from a bare private key |
|
|
125
|
+
| 1 | `POLY_PROXY` | email/Magic accounts (legacy) |
|
|
126
|
+
| 2 | `POLY_GNOSIS_SAFE` | browser-wallet proxy |
|
|
127
|
+
| 3 | `POLY_1271` deposit wallet | **the Polymarket app's default wallet** |
|
|
128
|
+
|
|
129
|
+
If `collateral()` returns 0 while the funds are visible on-chain on your funder
|
|
130
|
+
address, your `signature_type` is wrong. Debug trick: `eth_call` `owner()`
|
|
131
|
+
(`0x8da5cb5b`) on the funder; if it returns your EOA and the wallet bytecode is
|
|
132
|
+
an ERC-1167 proxy, you want `signature_type=3`.
|
|
133
|
+
|
|
134
|
+
## Comparison (2026-07-03, factual)
|
|
135
|
+
|
|
136
|
+
| | pmq | py-clob-client-v2 (official) | pmxt | NautilusTrader | caiovicentino MCP |
|
|
137
|
+
|---|---|---|---|---|---|
|
|
138
|
+
| CLOB V2 signing | yes, local | yes, local | writes via its hosted backend | yes, local | V1 only (rejected in prod since 2026-04-28) |
|
|
139
|
+
| Confirmed-fill contract | yes (core design) | no (raw responses) | n/a | engine-level | no |
|
|
140
|
+
| Deposit wallet / POLY_1271 | yes, production-proven | open issues (#70 and others) | n/a | untested claim | no |
|
|
141
|
+
| Fee math | official per-category formula | fee at match, no helper | via backend | fee model | fee-blind |
|
|
142
|
+
| Reconciliation helper | yes | no | n/a | engine-level | no |
|
|
143
|
+
| Footprint | one small lib | one small lib | multi-venue platform | full trading framework | MCP server |
|
|
144
|
+
|
|
145
|
+
NautilusTrader is excellent if you want a full framework; pmq is the small
|
|
146
|
+
library you embed in your own bot. pmxt is convenient if you accept routing
|
|
147
|
+
writes through their backend; pmq exists for self-custody.
|
|
148
|
+
|
|
149
|
+
## Builder code disclosure
|
|
150
|
+
|
|
151
|
+
pmq ships with the maintainer's public Polymarket **builder code** as default
|
|
152
|
+
attribution inside signed orders (`pmq.executor.DEFAULT_BUILDER_CODE`). Its
|
|
153
|
+
commission is set to **0/0: it never adds any fee to your orders**. Attribution
|
|
154
|
+
feeds Polymarket's builder program and funds this project at zero cost to you.
|
|
155
|
+
|
|
156
|
+
Opt out or replace it, one line either way:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
PolymarketExecutor(builder_code=None) # no attribution
|
|
160
|
+
PolymarketExecutor(builder_code="0xYOURS...") # your own code
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
or set the `POLY_BUILDER_CODE` environment variable. (Same model as
|
|
164
|
+
JKorf/Polymarket.Net; the official client defaults to zero attribution.)
|
|
165
|
+
|
|
166
|
+
## MCP server (agents)
|
|
167
|
+
|
|
168
|
+
`pip install "pmquant[mcp]"` then run `pmq-mcp` (stdio). Read tools (market,
|
|
169
|
+
book, taker_fee, account_collateral, account_trades) always exist. Trading
|
|
170
|
+
tools (`fak_buy`, `fak_sell`, `cancel_and_reconcile`) are **only registered
|
|
171
|
+
when the operator sets `PMQ_MCP_LIVE=1`** in the server environment: an
|
|
172
|
+
agent cannot talk its way past a tool that was never created. Every order is
|
|
173
|
+
capped per call by `PMQ_MCP_MAX_USD` (default 10).
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"mcpServers": {
|
|
178
|
+
"pmq": {
|
|
179
|
+
"command": "pmq-mcp",
|
|
180
|
+
"env": { "POLY_PRIVATE_KEY": "...", "POLY_FUNDER": "0x...", "POLY_SIG_TYPE": "3" }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Leave the `POLY_*` variables out entirely for a read-only market-data server.
|
|
187
|
+
|
|
188
|
+
## Bot template
|
|
189
|
+
|
|
190
|
+
[bot-template/](bot-template/) is a complete bot minus the strategy, for ANY
|
|
191
|
+
market (politics, sports, crypto, culture): paper mode against real books
|
|
192
|
+
with real fees, per-market budgets with fee headroom, poisoned-market
|
|
193
|
+
reconciliation, consecutive-failure halt, disk-persisted daily loss halt, a
|
|
194
|
+
systemd unit with `RestartPreventExitStatus=42` so halts stay halted, and a
|
|
195
|
+
lightweight phone dashboard. You implement `watchlist()` and `decide()`; the
|
|
196
|
+
shipped demo strategy is an API illustration meant to be replaced.
|
|
197
|
+
|
|
198
|
+
## Security posture
|
|
199
|
+
|
|
200
|
+
* Keys are read from the environment, used to instantiate the signer, and
|
|
201
|
+
never logged. No custody, no backend, no telemetry, zero network calls
|
|
202
|
+
besides Polymarket endpoints.
|
|
203
|
+
* Beware of the documented wave of fake "polymarket bot" repositories that
|
|
204
|
+
steal private keys. Read the source: pmq is small on purpose.
|
|
205
|
+
* Fund the trading wallet with what you can afford to lose. Nothing here is
|
|
206
|
+
financial advice; prediction-market access is restricted in some
|
|
207
|
+
jurisdictions and compliance is on you.
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pmq/__init__.py,sha256=PMuCY0rHDqRcuSJBpKgYIMqIwVfRc7Nm0LPF1YV8ig0,1274
|
|
2
|
+
pmq/data.py,sha256=GTOFGI-kMkycvO1cbTHVMY6Xdr90An86NJ8sJmPxQAU,7951
|
|
3
|
+
pmq/exceptions.py,sha256=A5O45Fv0Sa9whxb352pUHCKwvTyVFAMHznAhzrvkPk0,806
|
|
4
|
+
pmq/executor.py,sha256=EA4RYbzG2cZE70-XgyNGCKByBtyIleZjGk_uBnRSWo0,15602
|
|
5
|
+
pmq/mcp.py,sha256=u-B6NskztWDEun7Qc93psTQgpPUjE4UTCGosVvx3M94,7902
|
|
6
|
+
pmquant-0.1.0.dist-info/METADATA,sha256=NG6OcE4joCCXXIJ-Pdw2cbihBl6A19j3etN4r5ez2aM,9278
|
|
7
|
+
pmquant-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
pmquant-0.1.0.dist-info/entry_points.txt,sha256=KA0pbQsoNqTk8q05jSsalaEoxfTe9T_EzClj0rksTco,41
|
|
9
|
+
pmquant-0.1.0.dist-info/licenses/LICENSE,sha256=ONNtjWb-5LmWWI44Qg3AXhr4iDwHLVWEa0wHWOUSK3c,1064
|
|
10
|
+
pmquant-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 crp4222
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|