synpath 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.
- synpath/__init__.py +183 -0
- synpath/__main__.py +66 -0
- synpath/base.py +723 -0
- synpath/bucket.py +154 -0
- synpath/client.py +356 -0
- synpath/engine/__init__.py +37 -0
- synpath/engine/__main__.py +354 -0
- synpath/engine/alerts.py +170 -0
- synpath/engine/engine.py +888 -0
- synpath/engine/eod.py +154 -0
- synpath/engine/events.py +140 -0
- synpath/engine/fair_values.py +117 -0
- synpath/engine/feeds.py +220 -0
- synpath/engine/journal.py +907 -0
- synpath/engine/ledger.py +353 -0
- synpath/engine/orders/__init__.py +42 -0
- synpath/engine/orders/base.py +441 -0
- synpath/engine/orders/day.py +72 -0
- synpath/engine/orders/iceberg.py +121 -0
- synpath/engine/orders/manager.py +223 -0
- synpath/engine/orders/oco.py +255 -0
- synpath/engine/orders/peg.py +168 -0
- synpath/engine/orders/routed.py +496 -0
- synpath/engine/orders/stop.py +240 -0
- synpath/engine/orders/taker.py +187 -0
- synpath/engine/orders/twap.py +190 -0
- synpath/engine/paper.py +532 -0
- synpath/engine/reconcile.py +279 -0
- synpath/engine/risk.py +403 -0
- synpath/engine/router.py +261 -0
- synpath/errors.py +98 -0
- synpath/history.py +71 -0
- synpath/hosted.py +86 -0
- synpath/hosted_auth.py +201 -0
- synpath/ids.py +61 -0
- synpath/kalshi.py +1378 -0
- synpath/matching.py +86 -0
- synpath/polymarket.py +1004 -0
- synpath/polymarket_us.py +989 -0
- synpath/remote.py +195 -0
- synpath/server/__init__.py +98 -0
- synpath/server/__main__.py +118 -0
- synpath/server/api.py +439 -0
- synpath/server/errors.py +87 -0
- synpath/server/local.py +96 -0
- synpath/server/models.py +75 -0
- synpath/server/serve.py +236 -0
- synpath/server/store.py +363 -0
- synpath/server/trading.py +764 -0
- synpath/trading/__init__.py +79 -0
- synpath/trading/__main__.py +69 -0
- synpath/trading/base.py +126 -0
- synpath/trading/credentials.py +400 -0
- synpath/trading/errors.py +94 -0
- synpath/trading/init.py +233 -0
- synpath/trading/instruments.py +162 -0
- synpath/trading/kalshi.py +957 -0
- synpath/trading/limiter.py +177 -0
- synpath/trading/money.py +172 -0
- synpath/trading/polymarket.py +1362 -0
- synpath/trading/polymarket_signing.py +478 -0
- synpath/trading/polymarket_us.py +705 -0
- synpath/trading/polymarket_us_exchange.py +825 -0
- synpath/trading/types.py +414 -0
- synpath/types.py +608 -0
- synpath/ws/__init__.py +55 -0
- synpath/ws/base.py +544 -0
- synpath/ws/grpc.py +578 -0
- synpath/ws/kalshi.py +418 -0
- synpath/ws/polymarket.py +430 -0
- synpath/ws/polymarket_us.py +299 -0
- synpath/ws/polymarket_us_exchange.py +754 -0
- synpath-0.1.0.dist-info/METADATA +224 -0
- synpath-0.1.0.dist-info/RECORD +77 -0
- synpath-0.1.0.dist-info/WHEEL +4 -0
- synpath-0.1.0.dist-info/entry_points.txt +2 -0
- synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1362 @@
|
|
|
1
|
+
"""Polymarket (global CLOB V2) order entry.
|
|
2
|
+
|
|
3
|
+
Three facts about the venue shape this adapter.
|
|
4
|
+
|
|
5
|
+
**Every order is a signed message.** An order is an EIP-712 struct the wallet
|
|
6
|
+
signs; the CLOB matches it and the exchange contract settles it on Polygon.
|
|
7
|
+
Its id is the struct's hash, so the adapter knows the id before the order
|
|
8
|
+
is sent -- a journal can record it first, and a lost response is recovered
|
|
9
|
+
by asking for that id. Signing, amounts and wallet calls live in
|
|
10
|
+
`polymarket_signing`, checked byte for byte against the venue's client.
|
|
11
|
+
|
|
12
|
+
**YES and NO are separate tokens with separate books.** An order in this
|
|
13
|
+
library names a market and a side on the YES leg; this adapter picks the
|
|
14
|
+
token. `buy` buys the YES token at the price given. `sell` buys the NO token
|
|
15
|
+
at `1 - price`, because the venue has no netting and you cannot sell YES you
|
|
16
|
+
do not hold. With `reduce_only`, `sell` sells the YES tokens held instead,
|
|
17
|
+
and `buy` sells the NO tokens held. Everything the venue reports on the NO
|
|
18
|
+
token comes back converted to the YES leg, and a position is the two token
|
|
19
|
+
inventories netted, with both kept beside it. The Gamma catalog supplies
|
|
20
|
+
which token is which; the CLOB never says.
|
|
21
|
+
|
|
22
|
+
**A fill is not final when it matches.** A trade is `MATCHED`, then mined,
|
|
23
|
+
then `CONFIRMED` -- or `FAILED`, and then the tokens never arrived.
|
|
24
|
+
`Fill.settlement` carries which; code that acts on fills should act on
|
|
25
|
+
`confirmed` ones.
|
|
26
|
+
|
|
27
|
+
Wallets: a Deposit Wallet (signature type 3, the default for accounts
|
|
28
|
+
created since May 2026), a legacy proxy (1) or Safe (2), or an allowlisted
|
|
29
|
+
EOA (0). Orders work for all four. Approvals, split, merge and redeem go
|
|
30
|
+
through the gasless relayer for a Deposit Wallet and as the EOA's own
|
|
31
|
+
Polygon transactions for an EOA; the legacy proxy and Safe payloads are not
|
|
32
|
+
built here.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import math
|
|
40
|
+
import time
|
|
41
|
+
from dataclasses import dataclass, field, replace
|
|
42
|
+
from decimal import Decimal
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from ..base import AsyncHttpClient, Capability
|
|
46
|
+
from ..errors import (
|
|
47
|
+
AuthenticationError, BadRequest, ExchangeError, ExchangeNotAvailable, MarketNotFound, NotSupported,
|
|
48
|
+
RateLimitExceeded,
|
|
49
|
+
)
|
|
50
|
+
from .. import ids
|
|
51
|
+
from ..polymarket import parse_ts
|
|
52
|
+
from ..types import FeeSchedule, Page
|
|
53
|
+
from . import polymarket_signing as sig
|
|
54
|
+
from .base import TradingExchange
|
|
55
|
+
from .credentials import SYNPATH_BUILDER_CODE, PolymarketCredentials
|
|
56
|
+
from .errors import CredentialsMissing, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected
|
|
57
|
+
from .limiter import BudgetLimiter, Priority
|
|
58
|
+
from .money import D, from_poly_raw_amount, validate_amount, validate_price
|
|
59
|
+
from .types import (
|
|
60
|
+
Account, Balance, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType,
|
|
61
|
+
Position, PositionSide, Precision, SettlementState, Side, TimeInForce, VENUE_ORDER_TYPES,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
log = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
VENUE = "polymarket"
|
|
67
|
+
|
|
68
|
+
CLOB_URL = "https://clob.polymarket.com"
|
|
69
|
+
DATA_URL = "https://data-api.polymarket.com"
|
|
70
|
+
GAMMA_URL = "https://gamma-api.polymarket.com"
|
|
71
|
+
RELAYER_URL = "https://relayer-v2.polymarket.com"
|
|
72
|
+
|
|
73
|
+
BUILDER_SIGNER_URL = "https://api.synpath.dev/v1/builder/sign"
|
|
74
|
+
"""Synpath's builder signer. Polymarket's relayer accepts a builder's
|
|
75
|
+
credentials in place of the account's own Relayer API key; the builder secret
|
|
76
|
+
stays on Synpath's server, which answers with the headers for one request.
|
|
77
|
+
It moves no money -- the relayer only runs a batch the account's own key
|
|
78
|
+
signed. `SYNPATH_BUILDER_SIGNER_URL` points elsewhere, `none` turns it off."""
|
|
79
|
+
|
|
80
|
+
INITIAL_CURSOR = "MA=="
|
|
81
|
+
END_CURSOR = "LTE="
|
|
82
|
+
|
|
83
|
+
ORDER_BATCH = 15
|
|
84
|
+
"""Orders per `POST /orders`, the venue's limit."""
|
|
85
|
+
CANCEL_BATCH = 1000
|
|
86
|
+
"""Ids per `DELETE /orders`, the venue's limit since 2026-06-15."""
|
|
87
|
+
|
|
88
|
+
GTD_SAFETY_S = 60
|
|
89
|
+
"""The venue expires a GTD order one minute before its stated expiration."""
|
|
90
|
+
GTD_MIN_LEAD_S = 180
|
|
91
|
+
"""And refuses an expiration less than three minutes away."""
|
|
92
|
+
|
|
93
|
+
SIZE_STEP = Decimal("0.01")
|
|
94
|
+
"""Shares carry two decimals at every tick size."""
|
|
95
|
+
|
|
96
|
+
MARKET_TTL_S = 60.0
|
|
97
|
+
"""How long a token's tick, minimum and neg-risk flag are trusted. Ticks
|
|
98
|
+
change as a price nears the ends; a rejection for a stale tick also clears
|
|
99
|
+
the entry."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Market facts an order needs
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class TokenInfo:
|
|
108
|
+
"""What signing an order for one token requires."""
|
|
109
|
+
|
|
110
|
+
token_id: str
|
|
111
|
+
condition_id: str
|
|
112
|
+
tick: Decimal
|
|
113
|
+
min_size: Decimal
|
|
114
|
+
neg_risk: bool
|
|
115
|
+
read_at: float = field(default_factory=time.monotonic)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def precision(self) -> Precision:
|
|
119
|
+
return Precision(tick=self.tick, min_amount=self.min_size, amount_step=SIZE_STEP, whole_contracts=False)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_book(cls, token_id: str, book: dict[str, Any]) -> "TokenInfo":
|
|
123
|
+
return cls(
|
|
124
|
+
token_id=token_id,
|
|
125
|
+
condition_id=str(book.get("market") or ""),
|
|
126
|
+
tick=D(book.get("tick_size") or "0.01"),
|
|
127
|
+
min_size=D(book.get("min_order_size") or "0"),
|
|
128
|
+
neg_risk=bool(book.get("neg_risk")),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class MarketTokens:
|
|
134
|
+
"""Which token is which side of a market, from the Gamma catalog."""
|
|
135
|
+
|
|
136
|
+
gamma_id: str
|
|
137
|
+
condition_id: str
|
|
138
|
+
yes_token: str
|
|
139
|
+
no_token: str
|
|
140
|
+
neg_risk: bool = False
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def market_id(self) -> str:
|
|
144
|
+
return ids.qualify(VENUE, self.gamma_id)
|
|
145
|
+
|
|
146
|
+
def token(self, outcome: str) -> str:
|
|
147
|
+
return self.yes_token if outcome == "yes" else self.no_token
|
|
148
|
+
|
|
149
|
+
def outcome_of(self, token_id: str) -> str:
|
|
150
|
+
if token_id == self.yes_token:
|
|
151
|
+
return "yes"
|
|
152
|
+
if token_id == self.no_token:
|
|
153
|
+
return "no"
|
|
154
|
+
raise InvalidOrder(f"polymarket: token {token_id} is not a side of market {self.gamma_id}")
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_gamma(cls, raw: dict[str, Any]) -> "MarketTokens":
|
|
158
|
+
tokens = raw.get("clobTokenIds")
|
|
159
|
+
if isinstance(tokens, str):
|
|
160
|
+
tokens = json.loads(tokens or "[]")
|
|
161
|
+
tokens = [str(t) for t in (tokens or [])]
|
|
162
|
+
if len(tokens) != 2 or not raw.get("id"):
|
|
163
|
+
raise MarketNotFound(f"polymarket: market {raw.get('id')!r} publishes no token pair")
|
|
164
|
+
return cls(
|
|
165
|
+
gamma_id=str(raw["id"]), condition_id=str(raw.get("conditionId") or ""),
|
|
166
|
+
yes_token=tokens[0], no_token=tokens[1], neg_risk=bool(raw.get("negRisk")),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Pure translation
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def check_token_id(token_id: str) -> str:
|
|
175
|
+
if not token_id.isdigit():
|
|
176
|
+
raise InvalidOrder(f"polymarket: {token_id!r} is not a CLOB token id")
|
|
177
|
+
return token_id
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def wire_leg(request: OrderRequest) -> tuple[str, str]:
|
|
181
|
+
"""`(outcome, venue_side)`: which token the order goes to and whether it
|
|
182
|
+
buys or sells that token. See the module docstring."""
|
|
183
|
+
if request.side == Side.BUY:
|
|
184
|
+
return ("no", "SELL") if request.reduce_only else ("yes", "BUY")
|
|
185
|
+
return ("yes", "SELL") if request.reduce_only else ("no", "BUY")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def to_yes_leg(outcome: str, venue_side: str, price: Decimal | None) -> tuple[Side, Decimal | None]:
|
|
189
|
+
"""An order or fill the venue holds on one token, seen from the YES leg:
|
|
190
|
+
buying NO at 0.30 is selling at 0.70."""
|
|
191
|
+
buying = str(venue_side).upper() == "BUY"
|
|
192
|
+
if outcome == "yes":
|
|
193
|
+
return (Side.BUY if buying else Side.SELL), price
|
|
194
|
+
return (Side.SELL if buying else Side.BUY), (Decimal("1") - price if price is not None else None)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def order_type_of(request: OrderRequest) -> str:
|
|
198
|
+
"""The wire `orderType`: how long the unfilled part lives."""
|
|
199
|
+
if request.type not in VENUE_ORDER_TYPES:
|
|
200
|
+
raise InvalidOrder(
|
|
201
|
+
f"polymarket: {request.type.value} is held by the execution engine, not the venue; "
|
|
202
|
+
f"submit it through the engine"
|
|
203
|
+
)
|
|
204
|
+
tif = request.time_in_force
|
|
205
|
+
if tif == TimeInForce.DAY:
|
|
206
|
+
raise InvalidOrder("polymarket: 'day' is rewritten to 'gtd' by the engine; an adapter cannot pick a session end")
|
|
207
|
+
if request.type == OrderType.MARKET:
|
|
208
|
+
return "FOK" if tif == TimeInForce.FOK else "FAK"
|
|
209
|
+
return {TimeInForce.GTC: "GTC", TimeInForce.GTD: "GTD", TimeInForce.IOC: "FAK", TimeInForce.FOK: "FOK"}[tif]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def expiration_of(request: OrderRequest, *, now_s: int) -> int:
|
|
213
|
+
"""The wire `expiration`, seconds: the caller's expiry plus the minute the
|
|
214
|
+
venue takes off, so the order lives until the time asked for."""
|
|
215
|
+
if request.time_in_force != TimeInForce.GTD:
|
|
216
|
+
return 0
|
|
217
|
+
if request.expires_at is None:
|
|
218
|
+
raise InvalidOrder("polymarket: a gtd order needs expires_at")
|
|
219
|
+
expiration = math.ceil(request.expires_at / 1000) + GTD_SAFETY_S
|
|
220
|
+
if expiration < now_s + GTD_MIN_LEAD_S:
|
|
221
|
+
raise InvalidOrder(
|
|
222
|
+
f"polymarket: gtd orders must live at least {GTD_MIN_LEAD_S - GTD_SAFETY_S}s; the venue refuses an "
|
|
223
|
+
f"expiration under {GTD_MIN_LEAD_S}s away and expires orders {GTD_SAFETY_S}s early"
|
|
224
|
+
)
|
|
225
|
+
return expiration
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def validate_order(request: OrderRequest, info: TokenInfo, *, outcome: str = "yes") -> tuple[Decimal, Decimal]:
|
|
229
|
+
"""The token price and size as the venue will accept them, or
|
|
230
|
+
`InvalidOrder` saying why. `request.price` is the YES price; for an
|
|
231
|
+
order that goes to the NO token it is converted first."""
|
|
232
|
+
if request.price is None:
|
|
233
|
+
raise InvalidOrder(
|
|
234
|
+
"polymarket: a price is required -- a market order is sent as an immediate limit at the "
|
|
235
|
+
"protection price you give"
|
|
236
|
+
)
|
|
237
|
+
yes_price = D(request.price)
|
|
238
|
+
if not Decimal("0") < yes_price < Decimal("1"):
|
|
239
|
+
raise InvalidOrder(f"polymarket: price {yes_price} is outside (0, 1)")
|
|
240
|
+
price = validate_price(yes_price if outcome == "yes" else Decimal("1") - yes_price, info.precision)
|
|
241
|
+
if price < info.tick or price > 1 - info.tick:
|
|
242
|
+
raise InvalidOrder(f"polymarket: price {price} is outside [{info.tick}, {1 - info.tick}] for this tick")
|
|
243
|
+
size = validate_amount(D(request.amount), info.precision)
|
|
244
|
+
if request.post_only and request.time_in_force not in (TimeInForce.GTC, TimeInForce.GTD):
|
|
245
|
+
raise InvalidOrder("polymarket: post-only orders must be gtc or gtd")
|
|
246
|
+
if request.post_only and request.type == OrderType.MARKET:
|
|
247
|
+
raise InvalidOrder("polymarket: a market order cannot be post-only")
|
|
248
|
+
return price, size
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def build_signed_order(
|
|
252
|
+
request: OrderRequest, info: TokenInfo, *, signer: sig.WalletSigner, creds: PolymarketCredentials,
|
|
253
|
+
api_key: str, now_ms: int, salt: int | None = None,
|
|
254
|
+
) -> tuple[dict[str, Any], str]:
|
|
255
|
+
"""The `POST /order` entry and the order's id, before anything is sent.
|
|
256
|
+
`info` is the token the order goes to, chosen by `wire_leg`."""
|
|
257
|
+
try:
|
|
258
|
+
ids.native(VENUE, request.market_id)
|
|
259
|
+
except BadRequest as exc:
|
|
260
|
+
raise InvalidOrder(str(exc)) from None
|
|
261
|
+
outcome, side = wire_leg(request)
|
|
262
|
+
token_id = check_token_id(info.token_id)
|
|
263
|
+
price, size = validate_order(request, info, outcome=outcome)
|
|
264
|
+
order_type = order_type_of(request)
|
|
265
|
+
maker_amount, taker_amount = sig.limit_amounts(side, price, size, info.tick)
|
|
266
|
+
signature_type = creds.signature_type
|
|
267
|
+
maker = creds.funder if signature_type != sig.EOA and creds.funder else signer.address
|
|
268
|
+
order_signer = maker if signature_type == sig.DEPOSIT_WALLET else signer.address
|
|
269
|
+
builder = request.params.get("builder_code") or creds.builder_code
|
|
270
|
+
order = sig.build_order(
|
|
271
|
+
maker=maker, signer=order_signer, token_id=token_id,
|
|
272
|
+
maker_amount=maker_amount, taker_amount=taker_amount, side=side,
|
|
273
|
+
signature_type=signature_type, timestamp_ms=now_ms,
|
|
274
|
+
salt=salt if salt is not None else sig.new_salt(),
|
|
275
|
+
expiration=expiration_of(request, now_s=now_ms // 1000),
|
|
276
|
+
builder=builder,
|
|
277
|
+
)
|
|
278
|
+
order["signature"] = sig.sign_order(signer, order, neg_risk=info.neg_risk)
|
|
279
|
+
entry: dict[str, Any] = {
|
|
280
|
+
"deferExec": bool(request.params.get("defer_exec", False)),
|
|
281
|
+
"order": order,
|
|
282
|
+
"orderType": order_type,
|
|
283
|
+
"owner": api_key,
|
|
284
|
+
}
|
|
285
|
+
if request.post_only:
|
|
286
|
+
entry["postOnly"] = True
|
|
287
|
+
return entry, sig.order_hash(order, neg_risk=info.neg_risk)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def serialize(body: Any) -> str:
|
|
291
|
+
"""The exact text sent, which is the exact text the HMAC covers."""
|
|
292
|
+
return json.dumps(body, separators=(",", ":"), ensure_ascii=False)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# Normalizers
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
STATUS = {
|
|
300
|
+
"live": OrderStatus.OPEN,
|
|
301
|
+
"unmatched": OrderStatus.OPEN,
|
|
302
|
+
"delayed": OrderStatus.PENDING,
|
|
303
|
+
"matched": OrderStatus.CLOSED,
|
|
304
|
+
"canceled": OrderStatus.CANCELED,
|
|
305
|
+
"cancelled": OrderStatus.CANCELED,
|
|
306
|
+
"canceled_market_resolved": OrderStatus.CANCELED,
|
|
307
|
+
"invalid": OrderStatus.REJECTED,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
TIF = {"GTC": TimeInForce.GTC, "GTD": TimeInForce.GTD, "FAK": TimeInForce.IOC, "FOK": TimeInForce.FOK}
|
|
311
|
+
|
|
312
|
+
SETTLEMENT = {
|
|
313
|
+
"MATCHED": SettlementState.MATCHED,
|
|
314
|
+
"MINED": SettlementState.MATCHED,
|
|
315
|
+
"RETRYING": SettlementState.MATCHED,
|
|
316
|
+
"CONFIRMED": SettlementState.CONFIRMED,
|
|
317
|
+
"FAILED": SettlementState.FAILED,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _seconds_to_ms(value: Any) -> int | None:
|
|
322
|
+
if value in (None, "", 0, "0"):
|
|
323
|
+
return None
|
|
324
|
+
try:
|
|
325
|
+
return int(Decimal(str(value)) * 1000)
|
|
326
|
+
except Exception:
|
|
327
|
+
return parse_ts(value)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def order_of(
|
|
331
|
+
raw: dict[str, Any], *, market_id: str, outcome: str, account: Account | None = None,
|
|
332
|
+
) -> Order:
|
|
333
|
+
"""A CLOB order row (`GET /data/order`, `/data/orders`, the user stream),
|
|
334
|
+
on the YES leg. `outcome` says which token `raw["asset_id"]` is."""
|
|
335
|
+
amount = D(raw.get("original_size") or "0")
|
|
336
|
+
filled = D(raw.get("size_matched") or "0")
|
|
337
|
+
native = str(raw.get("status") or "").lower()
|
|
338
|
+
status = STATUS.get(native, OrderStatus.OPEN)
|
|
339
|
+
if status == OrderStatus.CLOSED and filled < amount:
|
|
340
|
+
# `matched` on a partly filled order still resting is not the end of it.
|
|
341
|
+
status = OrderStatus.OPEN
|
|
342
|
+
expiration = int(raw.get("expiration") or 0)
|
|
343
|
+
side, price = to_yes_leg(outcome, str(raw.get("side")), D(raw["price"]) if raw.get("price") not in (None, "") else None)
|
|
344
|
+
return Order(
|
|
345
|
+
id=str(raw.get("id") or ""),
|
|
346
|
+
venue=VENUE,
|
|
347
|
+
account=account,
|
|
348
|
+
market_id=market_id,
|
|
349
|
+
side=side,
|
|
350
|
+
type=OrderType.LIMIT,
|
|
351
|
+
time_in_force=TIF.get(str(raw.get("order_type") or "GTC").upper(), TimeInForce.GTC),
|
|
352
|
+
status=status,
|
|
353
|
+
price=price,
|
|
354
|
+
amount=amount,
|
|
355
|
+
filled=filled,
|
|
356
|
+
remaining=max(Decimal("0"), amount - filled) if status == OrderStatus.OPEN else Decimal("0"),
|
|
357
|
+
expires_at=(expiration - GTD_SAFETY_S) * 1000 if expiration else None,
|
|
358
|
+
created_at=_seconds_to_ms(raw.get("created_at")),
|
|
359
|
+
updated_at=_seconds_to_ms(raw.get("timestamp")) if raw.get("timestamp") else None,
|
|
360
|
+
info=raw,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def order_from_response(
|
|
365
|
+
raw: dict[str, Any], *, entry: dict[str, Any], order_id: str, request: OrderRequest,
|
|
366
|
+
info: TokenInfo, account: Account,
|
|
367
|
+
) -> Order:
|
|
368
|
+
"""The `POST /order` answer, joined with the order that produced it.
|
|
369
|
+
|
|
370
|
+
The answer carries a status word and nothing about how much filled in
|
|
371
|
+
shares, so a `live` order is reported open with nothing filled and a
|
|
372
|
+
`matched` or `delayed` one is left for the caller to read back.
|
|
373
|
+
"""
|
|
374
|
+
order = entry["order"]
|
|
375
|
+
shares_raw = int(order["takerAmount"] if order["side"] == "BUY" else order["makerAmount"])
|
|
376
|
+
native = str(raw.get("status") or "").lower()
|
|
377
|
+
status = STATUS.get(native, OrderStatus.PENDING)
|
|
378
|
+
if native == "matched":
|
|
379
|
+
status = OrderStatus.PENDING # how much matched is read back, not guessed
|
|
380
|
+
expiration = int(order["expiration"])
|
|
381
|
+
return Order(
|
|
382
|
+
id=str(raw.get("orderID") or order_id),
|
|
383
|
+
client_order_id=request.client_order_id,
|
|
384
|
+
venue=VENUE,
|
|
385
|
+
account=account,
|
|
386
|
+
market_id=ids.qualify(VENUE, ids.native(VENUE, request.market_id)),
|
|
387
|
+
side=request.side,
|
|
388
|
+
type=request.type,
|
|
389
|
+
time_in_force=TIF[entry["orderType"]],
|
|
390
|
+
status=status,
|
|
391
|
+
price=D(request.price) if request.price is not None else None,
|
|
392
|
+
amount=Decimal(shares_raw) / sig.SCALE,
|
|
393
|
+
filled=Decimal("0"),
|
|
394
|
+
post_only=bool(entry.get("postOnly")),
|
|
395
|
+
expires_at=(expiration - GTD_SAFETY_S) * 1000 if expiration else None,
|
|
396
|
+
created_at=int(order["timestamp"]),
|
|
397
|
+
book=request.book,
|
|
398
|
+
trader=request.trader,
|
|
399
|
+
tags=request.tags,
|
|
400
|
+
info={"response": raw, "request": entry, "computed_id": order_id},
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def fills_of(
|
|
405
|
+
raw: dict[str, Any], *, api_key: str, wallets: set[str], market_id: str, outcome_of: Any,
|
|
406
|
+
account: Account | None = None,
|
|
407
|
+
) -> list[Fill]:
|
|
408
|
+
"""One CLOB trade as this account's fills, on the YES leg. `outcome_of`
|
|
409
|
+
maps a token id to `"yes"` / `"no"`; a trade can match a YES buy against
|
|
410
|
+
a NO buy, so each leg is looked up on its own.
|
|
411
|
+
|
|
412
|
+
As the taker, the trade row is the fill. As a maker, each of this
|
|
413
|
+
account's orders in `maker_orders` is its own fill, at that order's
|
|
414
|
+
price and size. The row's `status` is the settlement state of all of
|
|
415
|
+
them.
|
|
416
|
+
"""
|
|
417
|
+
status = str(raw.get("status") or "").upper().removeprefix("TRADE_STATUS_")
|
|
418
|
+
settlement = SETTLEMENT.get(status, SettlementState.MATCHED)
|
|
419
|
+
stamp = _seconds_to_ms(raw.get("match_time")) or 0
|
|
420
|
+
trade_id = str(raw.get("id") or "")
|
|
421
|
+
lowered = {w.lower() for w in wallets}
|
|
422
|
+
fills: list[Fill] = []
|
|
423
|
+
if str(raw.get("trader_side") or "").upper() == "TAKER":
|
|
424
|
+
side, price = to_yes_leg(outcome_of(str(raw.get("asset_id") or "")), str(raw.get("side")), D(raw["price"]))
|
|
425
|
+
fills.append(Fill(
|
|
426
|
+
id=trade_id, order_id=str(raw.get("taker_order_id") or ""), venue=VENUE, account=account,
|
|
427
|
+
market_id=market_id,
|
|
428
|
+
side=side,
|
|
429
|
+
price=price, amount=D(raw.get("size") or "0"),
|
|
430
|
+
fee=None, fee_currency="pUSD", liquidity=Liquidity.TAKER,
|
|
431
|
+
settlement=settlement, timestamp=stamp, info=raw,
|
|
432
|
+
))
|
|
433
|
+
return fills
|
|
434
|
+
for leg in raw.get("maker_orders") or []:
|
|
435
|
+
mine = leg.get("owner") == api_key or str(leg.get("maker_address") or "").lower() in lowered
|
|
436
|
+
if not mine:
|
|
437
|
+
continue
|
|
438
|
+
side, price = to_yes_leg(
|
|
439
|
+
outcome_of(str(leg.get("asset_id") or raw.get("asset_id") or "")), str(leg.get("side")), D(leg["price"]),
|
|
440
|
+
)
|
|
441
|
+
fills.append(Fill(
|
|
442
|
+
id=f"{trade_id}:{leg.get('order_id')}", order_id=str(leg.get("order_id") or ""), venue=VENUE,
|
|
443
|
+
account=account, market_id=market_id,
|
|
444
|
+
side=side,
|
|
445
|
+
price=price, amount=D(leg.get("matched_amount") or "0"),
|
|
446
|
+
fee=Decimal("0"), fee_currency="pUSD", liquidity=Liquidity.MAKER,
|
|
447
|
+
settlement=settlement, timestamp=stamp, info={"trade": raw, "maker_order": leg},
|
|
448
|
+
))
|
|
449
|
+
return fills
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def position_of(
|
|
453
|
+
rows: list[dict[str, Any]], *, market_id: str, outcome_of: Any, account: Account | None = None,
|
|
454
|
+
) -> Position:
|
|
455
|
+
"""A market's position from its Data API v2 rows, one per token held:
|
|
456
|
+
the two inventories netted on the YES leg, and both kept."""
|
|
457
|
+
yes_rows = [r for r in rows if outcome_of(str(r.get("token_id") or "")) == "yes"]
|
|
458
|
+
no_rows = [r for r in rows if outcome_of(str(r.get("token_id") or "")) == "no"]
|
|
459
|
+
held_yes = sum((D(r.get("current_size") or "0") for r in yes_rows), Decimal("0"))
|
|
460
|
+
held_no = sum((D(r.get("current_size") or "0") for r in no_rows), Decimal("0"))
|
|
461
|
+
net = held_yes - held_no
|
|
462
|
+
# The side with inventory names the row the figures come from.
|
|
463
|
+
raw = (yes_rows if net >= 0 and yes_rows else no_rows or yes_rows)[0]
|
|
464
|
+
outcome = "yes" if raw in yes_rows else "no"
|
|
465
|
+
status = str(raw.get("status") or "OPEN").upper()
|
|
466
|
+
redeemable = bool(raw.get("redeemable"))
|
|
467
|
+
value = raw.get("current_value")
|
|
468
|
+
|
|
469
|
+
def yes_leg(value: Any) -> Decimal | None:
|
|
470
|
+
if value in (None, 0, 0.0, ""):
|
|
471
|
+
return None
|
|
472
|
+
return D(value) if outcome == "yes" else Decimal("1") - D(value)
|
|
473
|
+
|
|
474
|
+
return Position(
|
|
475
|
+
venue=VENUE,
|
|
476
|
+
account=account,
|
|
477
|
+
market_id=market_id,
|
|
478
|
+
side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT,
|
|
479
|
+
contracts=abs(net),
|
|
480
|
+
inventory_yes=held_yes,
|
|
481
|
+
inventory_no=held_no,
|
|
482
|
+
entry_price=yes_leg(raw.get("avg_price")),
|
|
483
|
+
mark_price=yes_leg(raw.get("current_price")),
|
|
484
|
+
unrealized_pnl=sum((D(r["unrealized_pnl"]) for r in rows if r.get("unrealized_pnl") is not None), Decimal("0"))
|
|
485
|
+
if any(r.get("unrealized_pnl") is not None for r in rows) else None,
|
|
486
|
+
realized_pnl=sum((D(r["realized_pnl"]) for r in rows if r.get("realized_pnl") is not None), Decimal("0"))
|
|
487
|
+
if any(r.get("realized_pnl") is not None for r in rows) else None,
|
|
488
|
+
resolved=redeemable or status == "REDEEMABLE",
|
|
489
|
+
final=False,
|
|
490
|
+
redeemable=D(value) if redeemable and value is not None else None,
|
|
491
|
+
timestamp=max((int(r["last_event_at"]) * 1000 for r in rows if r.get("last_event_at")), default=None),
|
|
492
|
+
info={"rows": rows},
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def error_of(message: str, *, body: Any = None, status: int | None = None) -> ExchangeError:
|
|
497
|
+
"""The venue's error text as a typed error. The venue sends text, not
|
|
498
|
+
codes, for most refusals; the phrases matched here are the documented
|
|
499
|
+
ones."""
|
|
500
|
+
text = message.lower()
|
|
501
|
+
if "not enough balance" in text or "allowance" in text:
|
|
502
|
+
return InsufficientFunds(message, body=body, status=status)
|
|
503
|
+
if "tick size" in text or "lower than the minimum" in text or "invalid expiration" in text:
|
|
504
|
+
return InvalidOrder(message)
|
|
505
|
+
if "cancel-only" in text or "post-only mode" in text or "trading is currently disabled" in text \
|
|
506
|
+
or "not yet ready" in text or "closed only mode" in text:
|
|
507
|
+
return MarketHalted(message, body=body, status=status)
|
|
508
|
+
if "not found" in text or "does not exist" in text:
|
|
509
|
+
return OrderNotFound(message, body=body, status=status)
|
|
510
|
+
reason = (
|
|
511
|
+
"post_only_would_cross" if "crosses book" in text or "crosses the book" in text
|
|
512
|
+
else "fok_not_filled" if "fully filled or killed" in text
|
|
513
|
+
else "fak_no_match" if "no orders found to match" in text
|
|
514
|
+
else "duplicate" if "duplicated" in text
|
|
515
|
+
else None
|
|
516
|
+
)
|
|
517
|
+
return OrderRejected(message, reason=reason, info=body if isinstance(body, dict) else {}, body=body, status=status)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# ---------------------------------------------------------------------------
|
|
521
|
+
# Adapter
|
|
522
|
+
# ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
class PolymarketTrading(TradingExchange):
|
|
525
|
+
"""Polymarket CLOB V2 order entry.
|
|
526
|
+
|
|
527
|
+
```python
|
|
528
|
+
from synpath.trading.credentials import load_credentials, require
|
|
529
|
+
from synpath.trading.polymarket import PolymarketTrading
|
|
530
|
+
|
|
531
|
+
creds = require("polymarket", load_credentials())
|
|
532
|
+
async with PolymarketTrading(creds) as poly:
|
|
533
|
+
await poly.ensure_api_credentials()
|
|
534
|
+
print(await poly.fetch_balance())
|
|
535
|
+
```
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
id = VENUE
|
|
539
|
+
name = "Polymarket"
|
|
540
|
+
has: dict[str, Capability] = {
|
|
541
|
+
"create_order": True,
|
|
542
|
+
"create_orders": True,
|
|
543
|
+
"cancel_order": True,
|
|
544
|
+
"cancel_orders": True,
|
|
545
|
+
"cancel_all_orders": True,
|
|
546
|
+
# No amend on the CLOB: a change is a cancel and a new signed order,
|
|
547
|
+
# which is the engine's to do.
|
|
548
|
+
"edit_order": False,
|
|
549
|
+
"fetch_order": True,
|
|
550
|
+
"fetch_open_orders": True,
|
|
551
|
+
# The order endpoint lists live orders only; closed ones are reached
|
|
552
|
+
# by id or through their trades.
|
|
553
|
+
"fetch_orders": False,
|
|
554
|
+
"fetch_my_trades": True,
|
|
555
|
+
"fetch_positions": True,
|
|
556
|
+
"fetch_balance": True,
|
|
557
|
+
"fetch_settlements": True,
|
|
558
|
+
"fetch_queue_position": False,
|
|
559
|
+
"fetch_fee_estimate": True,
|
|
560
|
+
"rfq": False,
|
|
561
|
+
# Deposit Wallets through the relayer and EOAs on-chain; the legacy
|
|
562
|
+
# proxy and Safe wallet payloads are not built.
|
|
563
|
+
"split_merge": "partial",
|
|
564
|
+
"watch_orders": False,
|
|
565
|
+
"watch_my_trades": False,
|
|
566
|
+
"watch_positions": False,
|
|
567
|
+
"watch_balance": False,
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
def __init__(
|
|
571
|
+
self,
|
|
572
|
+
credentials: PolymarketCredentials,
|
|
573
|
+
*,
|
|
574
|
+
account_name: str = "default",
|
|
575
|
+
clob_url: str = CLOB_URL,
|
|
576
|
+
data_url: str = DATA_URL,
|
|
577
|
+
relayer_url: str = RELAYER_URL,
|
|
578
|
+
gamma_url: str = GAMMA_URL,
|
|
579
|
+
limiter: BudgetLimiter | None = None,
|
|
580
|
+
timeout: float = 30.0,
|
|
581
|
+
client: Any = None,
|
|
582
|
+
builder_signer_url: str | None = None,
|
|
583
|
+
):
|
|
584
|
+
import httpx
|
|
585
|
+
import os
|
|
586
|
+
|
|
587
|
+
self.credentials = credentials
|
|
588
|
+
configured = builder_signer_url or os.environ.get("SYNPATH_BUILDER_SIGNER_URL") or BUILDER_SIGNER_URL
|
|
589
|
+
self.builder_signer_url = None if configured.strip().lower() == "none" else configured
|
|
590
|
+
self.signer = sig.WalletSigner(credentials.private_key)
|
|
591
|
+
if credentials.signature_type != sig.EOA and not credentials.funder:
|
|
592
|
+
raise CredentialsMissing("polymarket: signature types 1-3 spend from a smart wallet; the funder address is required")
|
|
593
|
+
self.wallet = credentials.funder if credentials.signature_type != sig.EOA and credentials.funder else self.signer.address
|
|
594
|
+
self.account = Account(venue=VENUE, name=account_name)
|
|
595
|
+
# The venue's order limits are 200 a second sustained; reads share
|
|
596
|
+
# the same generous budget. Cancels take the fast lane.
|
|
597
|
+
self.limiter = limiter or BudgetLimiter(read_per_second=50, write_per_second=50)
|
|
598
|
+
shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True)
|
|
599
|
+
self.clob = AsyncHttpClient(clob_url, limiter=None, client=shared, venue=VENUE)
|
|
600
|
+
self.data = AsyncHttpClient(data_url, limiter=None, client=shared, venue=VENUE)
|
|
601
|
+
self.relayer = AsyncHttpClient(relayer_url, limiter=None, client=shared, venue=VENUE)
|
|
602
|
+
self.gamma = AsyncHttpClient(gamma_url, limiter=None, client=shared, venue=VENUE)
|
|
603
|
+
self._markets: dict[str, MarketTokens] = {}
|
|
604
|
+
"""By Gamma id, condition id and either token id: which token is which side."""
|
|
605
|
+
self._http = shared
|
|
606
|
+
self._api_key = credentials.api_key
|
|
607
|
+
self._api_secret = credentials.api_secret
|
|
608
|
+
self._api_passphrase = credentials.api_passphrase
|
|
609
|
+
self._tokens: dict[str, TokenInfo] = {}
|
|
610
|
+
self._heartbeat_task: asyncio.Task | None = None
|
|
611
|
+
self._heartbeat_id = ""
|
|
612
|
+
self.heartbeat_error: BaseException | None = None
|
|
613
|
+
self.clock_offset_s = 0
|
|
614
|
+
self._default_builder_refused = False
|
|
615
|
+
"""Set once the venue refuses Synpath's default builder code; orders
|
|
616
|
+
are then signed without it for the rest of this session."""
|
|
617
|
+
|
|
618
|
+
# -- auth -----------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
def _now_s(self) -> int:
|
|
621
|
+
return int(time.time()) + self.clock_offset_s
|
|
622
|
+
|
|
623
|
+
async def sync_clock(self) -> int:
|
|
624
|
+
"""Adopt the venue's clock for signed timestamps; returns the offset in seconds."""
|
|
625
|
+
server = await self.clob.get("/time")
|
|
626
|
+
stamp = int(server.get("time") or server.get("timestamp")) if isinstance(server, dict) else int(server)
|
|
627
|
+
self.clock_offset_s = stamp - int(time.time())
|
|
628
|
+
return self.clock_offset_s
|
|
629
|
+
|
|
630
|
+
async def ensure_api_credentials(self, *, nonce: int = 0) -> str:
|
|
631
|
+
"""The CLOB API key, deriving it from the wallet key when none was given.
|
|
632
|
+
|
|
633
|
+
Derivation is deterministic for a signer and nonce, so it returns the
|
|
634
|
+
same credentials every time; only when none exist yet is one created.
|
|
635
|
+
The secret stays in memory and is registered with the log filter.
|
|
636
|
+
"""
|
|
637
|
+
if self._api_key and self._api_secret and self._api_passphrase:
|
|
638
|
+
return self._api_key
|
|
639
|
+
from .credentials import SecretFilter
|
|
640
|
+
|
|
641
|
+
headers = sig.l1_headers(self.signer, self._now_s(), nonce)
|
|
642
|
+
try:
|
|
643
|
+
raw = await self.clob.get("/auth/derive-api-key", headers=headers)
|
|
644
|
+
except (BadRequest, MarketNotFound, AuthenticationError):
|
|
645
|
+
raw = None
|
|
646
|
+
if not raw or not raw.get("apiKey"):
|
|
647
|
+
headers = sig.l1_headers(self.signer, self._now_s(), nonce)
|
|
648
|
+
raw = await self.clob.post("/auth/api-key", headers=headers)
|
|
649
|
+
self._api_key, self._api_secret, self._api_passphrase = raw["apiKey"], raw["secret"], raw["passphrase"]
|
|
650
|
+
SecretFilter.install([self._api_secret, self._api_passphrase])
|
|
651
|
+
return self._api_key
|
|
652
|
+
|
|
653
|
+
async def _private(
|
|
654
|
+
self, method: str, path: str, *, params: dict[str, Any] | None = None, body: Any = None,
|
|
655
|
+
kind: str = "read", cost: float = 1, priority: Priority = Priority.NORMAL,
|
|
656
|
+
) -> Any:
|
|
657
|
+
"""One L2-authenticated CLOB call: paced, signed over the exact body, errors typed."""
|
|
658
|
+
await self.ensure_api_credentials()
|
|
659
|
+
await self.limiter.acquire(cost=cost, kind=kind, priority=priority) # type: ignore[arg-type]
|
|
660
|
+
text = serialize(body) if body is not None else None
|
|
661
|
+
headers = sig.l2_headers(
|
|
662
|
+
address=self.signer.address, api_key=self._api_key or "", secret=self._api_secret or "",
|
|
663
|
+
passphrase=self._api_passphrase or "", timestamp=self._now_s(), method=method, path=path, body=text,
|
|
664
|
+
)
|
|
665
|
+
if text is not None:
|
|
666
|
+
headers["Content-Type"] = "application/json"
|
|
667
|
+
try:
|
|
668
|
+
return await self.clob.request(
|
|
669
|
+
method, path, params={k: v for k, v in (params or {}).items() if v is not None} or None,
|
|
670
|
+
content=text, headers=headers,
|
|
671
|
+
)
|
|
672
|
+
except ExchangeNotAvailable as exc:
|
|
673
|
+
detail = _error_text(exc.body)
|
|
674
|
+
if detail:
|
|
675
|
+
raise error_of(detail, body=exc.body, status=exc.status) from None
|
|
676
|
+
raise
|
|
677
|
+
except (AuthenticationError, MarketNotFound) as exc:
|
|
678
|
+
if isinstance(exc, MarketNotFound) and "/order" in path:
|
|
679
|
+
raise OrderNotFound(str(exc), body=exc.body, status=exc.status) from None
|
|
680
|
+
raise
|
|
681
|
+
except ExchangeError as exc:
|
|
682
|
+
detail = _error_text(exc.body) or str(exc)
|
|
683
|
+
raise error_of(detail, body=exc.body, status=exc.status) from None
|
|
684
|
+
|
|
685
|
+
# -- market facts ---------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
async def token_info(self, token_id: str, *, refresh: bool = False) -> TokenInfo:
|
|
688
|
+
"""Tick, minimum size, neg-risk flag and condition id for a token,
|
|
689
|
+
read from its book and cached briefly."""
|
|
690
|
+
check_token_id(token_id)
|
|
691
|
+
cached = self._tokens.get(token_id)
|
|
692
|
+
if cached and not refresh and time.monotonic() - cached.read_at < MARKET_TTL_S:
|
|
693
|
+
return cached
|
|
694
|
+
await self.limiter.acquire(cost=1, kind="read")
|
|
695
|
+
try:
|
|
696
|
+
book = await self.clob.get("/book", {"token_id": token_id})
|
|
697
|
+
except MarketNotFound:
|
|
698
|
+
raise InvalidOrder(f"polymarket: no order book for token {token_id}; the market is closed or unknown") from None
|
|
699
|
+
info = TokenInfo.from_book(token_id, book or {})
|
|
700
|
+
self._tokens[token_id] = info
|
|
701
|
+
return info
|
|
702
|
+
|
|
703
|
+
def remember_token(self, info: TokenInfo) -> None:
|
|
704
|
+
"""Seed the cache from elsewhere (the read API, a market stream), saving the lookup."""
|
|
705
|
+
self._tokens[info.token_id] = info
|
|
706
|
+
|
|
707
|
+
def remember_market(self, tokens: MarketTokens) -> MarketTokens:
|
|
708
|
+
"""Seed which token is which side, from a `Market` the read API returned
|
|
709
|
+
(`MarketTokens(market.venue_market_id, market.info["conditionId"],
|
|
710
|
+
market.yes.venue_token_id, market.no.venue_token_id)`) or a catalog row."""
|
|
711
|
+
for key in (tokens.gamma_id, tokens.condition_id, tokens.yes_token, tokens.no_token):
|
|
712
|
+
if key:
|
|
713
|
+
self._markets[key] = tokens
|
|
714
|
+
return tokens
|
|
715
|
+
|
|
716
|
+
async def market_tokens(self, market_id: str) -> MarketTokens:
|
|
717
|
+
"""Which token is which side of a market, by Synpath id, Gamma id or
|
|
718
|
+
condition id. One Gamma read the first time, cached after."""
|
|
719
|
+
native = ids.native(VENUE, market_id)
|
|
720
|
+
cached = self._markets.get(native)
|
|
721
|
+
if cached:
|
|
722
|
+
return cached
|
|
723
|
+
await self.limiter.acquire(cost=1, kind="read")
|
|
724
|
+
if native.startswith("0x"):
|
|
725
|
+
rows = await self.gamma.get("/markets", {"condition_ids": native})
|
|
726
|
+
raw = rows[0] if isinstance(rows, list) and rows else None
|
|
727
|
+
else:
|
|
728
|
+
raw = await self.gamma.get(f"/markets/{native}")
|
|
729
|
+
if not isinstance(raw, dict) or not raw.get("id"):
|
|
730
|
+
raise MarketNotFound(f"polymarket: no market {native}")
|
|
731
|
+
return self.remember_market(MarketTokens.from_gamma(raw))
|
|
732
|
+
|
|
733
|
+
async def locate_token(self, token_id: str) -> MarketTokens:
|
|
734
|
+
"""The market behind a token id the venue reported."""
|
|
735
|
+
cached = self._markets.get(token_id)
|
|
736
|
+
if cached:
|
|
737
|
+
return cached
|
|
738
|
+
await self.limiter.acquire(cost=1, kind="read")
|
|
739
|
+
rows = await self.gamma.get("/markets", {"clob_token_ids": token_id})
|
|
740
|
+
raw = rows[0] if isinstance(rows, list) and rows else None
|
|
741
|
+
if not isinstance(raw, dict) or not raw.get("id"):
|
|
742
|
+
raise MarketNotFound(f"polymarket: no market behind token {token_id}")
|
|
743
|
+
return self.remember_market(MarketTokens.from_gamma(raw))
|
|
744
|
+
|
|
745
|
+
async def _outcome_of(self, token_id: str) -> str:
|
|
746
|
+
return (await self.locate_token(token_id)).outcome_of(token_id)
|
|
747
|
+
|
|
748
|
+
async def _order_of(self, raw: dict[str, Any]) -> Order:
|
|
749
|
+
token = str(raw.get("asset_id") or "")
|
|
750
|
+
tokens = await self.locate_token(token)
|
|
751
|
+
return order_of(raw, market_id=tokens.market_id, outcome=tokens.outcome_of(token), account=self.account)
|
|
752
|
+
|
|
753
|
+
# -- orders ---------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
async def create_order(self, request: OrderRequest, *, token: TokenInfo | None = None) -> Order:
|
|
756
|
+
"""Sign and place one order.
|
|
757
|
+
|
|
758
|
+
`live` comes back open; `matched` and `delayed` come back pending,
|
|
759
|
+
because the answer does not say how much matched -- read the order
|
|
760
|
+
back, or follow its trades.
|
|
761
|
+
"""
|
|
762
|
+
if token is None:
|
|
763
|
+
tokens = await self.market_tokens(request.market_id)
|
|
764
|
+
token = await self.token_info(tokens.token(wire_leg(request)[0]))
|
|
765
|
+
info = token
|
|
766
|
+
api_key = await self.ensure_api_credentials()
|
|
767
|
+
try:
|
|
768
|
+
return await self._place(request, info, api_key)
|
|
769
|
+
except ExchangeError as exc:
|
|
770
|
+
if not self._refuses_default_builder(request, exc):
|
|
771
|
+
raise
|
|
772
|
+
self._drop_default_builder(exc)
|
|
773
|
+
return await self._place(request, info, api_key)
|
|
774
|
+
|
|
775
|
+
async def _place(self, request: OrderRequest, info: TokenInfo, api_key: str) -> Order:
|
|
776
|
+
entry, order_id = build_signed_order(
|
|
777
|
+
request, info, signer=self.signer, creds=self._order_credentials(), api_key=api_key,
|
|
778
|
+
now_ms=self._now_s() * 1000,
|
|
779
|
+
)
|
|
780
|
+
try:
|
|
781
|
+
raw = await self._private("POST", "/order", body=entry, kind="write", cost=1)
|
|
782
|
+
except InvalidOrder as exc:
|
|
783
|
+
if "tick" in str(exc).lower():
|
|
784
|
+
self._tokens.pop(info.token_id, None)
|
|
785
|
+
raise
|
|
786
|
+
if not raw or not raw.get("success", True) or raw.get("errorMsg"):
|
|
787
|
+
raise error_of(str((raw or {}).get("errorMsg") or "order refused"), body=raw)
|
|
788
|
+
return order_from_response(raw, entry=entry, order_id=order_id, request=request, info=info, account=request.account or self.account)
|
|
789
|
+
|
|
790
|
+
def _order_credentials(self) -> PolymarketCredentials:
|
|
791
|
+
"""The credentials orders are signed with: without Synpath's default
|
|
792
|
+
builder code once the venue has refused it."""
|
|
793
|
+
if self._default_builder_refused and self.credentials.builder_code == SYNPATH_BUILDER_CODE:
|
|
794
|
+
return replace(self.credentials, builder_code=None)
|
|
795
|
+
return self.credentials
|
|
796
|
+
|
|
797
|
+
def _refuses_default_builder(self, request: OrderRequest, exc: Exception) -> bool:
|
|
798
|
+
"""Whether a refusal is the venue rejecting Synpath's default builder
|
|
799
|
+
code (a code it has disabled is refused outright). A code the account
|
|
800
|
+
or the order chose itself is left to fail: that was its choice."""
|
|
801
|
+
return (
|
|
802
|
+
not self._default_builder_refused
|
|
803
|
+
and self.credentials.builder_code == SYNPATH_BUILDER_CODE
|
|
804
|
+
and not request.params.get("builder_code")
|
|
805
|
+
and "builder" in str(exc).lower()
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
def _drop_default_builder(self, exc: Exception) -> None:
|
|
809
|
+
self._default_builder_refused = True
|
|
810
|
+
log.warning("polymarket: the venue refused Synpath's builder code (%s); sending orders without it", exc)
|
|
811
|
+
|
|
812
|
+
async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]:
|
|
813
|
+
"""Many orders, fifteen to a request. Each is validated and signed
|
|
814
|
+
before anything is sent; one refused locally or by the venue does
|
|
815
|
+
not fail the rest."""
|
|
816
|
+
api_key = await self.ensure_api_credentials()
|
|
817
|
+
prepared: list[tuple[dict[str, Any], str, TokenInfo] | Exception] = []
|
|
818
|
+
for request in requests:
|
|
819
|
+
try:
|
|
820
|
+
tokens = await self.market_tokens(request.market_id)
|
|
821
|
+
info = await self.token_info(tokens.token(wire_leg(request)[0]))
|
|
822
|
+
entry, order_id = build_signed_order(
|
|
823
|
+
request, info, signer=self.signer, creds=self._order_credentials(), api_key=api_key,
|
|
824
|
+
now_ms=self._now_s() * 1000,
|
|
825
|
+
)
|
|
826
|
+
prepared.append((entry, order_id, info))
|
|
827
|
+
except InvalidOrder as exc:
|
|
828
|
+
prepared.append(exc)
|
|
829
|
+
results: list[Order | Exception] = [p if isinstance(p, Exception) else p for p in prepared] # type: ignore[misc]
|
|
830
|
+
sendable = [i for i, p in enumerate(prepared) if not isinstance(p, Exception)]
|
|
831
|
+
retry: list[int] = []
|
|
832
|
+
for start in range(0, len(sendable), ORDER_BATCH):
|
|
833
|
+
chunk = sendable[start:start + ORDER_BATCH]
|
|
834
|
+
body = [prepared[i][0] for i in chunk] # type: ignore[index]
|
|
835
|
+
raw = await self._private("POST", "/orders", body=body, kind="write", cost=len(chunk))
|
|
836
|
+
answers = raw if isinstance(raw, list) else []
|
|
837
|
+
for offset, index in enumerate(chunk):
|
|
838
|
+
entry, order_id, info = prepared[index] # type: ignore[misc]
|
|
839
|
+
answer = answers[offset] if offset < len(answers) else {}
|
|
840
|
+
if not answer.get("success", False) or answer.get("errorMsg"):
|
|
841
|
+
refusal = error_of(str(answer.get("errorMsg") or "order refused"), body=answer)
|
|
842
|
+
if self._refuses_default_builder(requests[index], refusal) or (
|
|
843
|
+
self._default_builder_refused and "builder" in str(refusal).lower()
|
|
844
|
+
and entry["order"]["builder"] == SYNPATH_BUILDER_CODE
|
|
845
|
+
):
|
|
846
|
+
if not self._default_builder_refused:
|
|
847
|
+
self._drop_default_builder(refusal)
|
|
848
|
+
retry.append(index)
|
|
849
|
+
results[index] = refusal
|
|
850
|
+
else:
|
|
851
|
+
results[index] = order_from_response(
|
|
852
|
+
answer, entry=entry, order_id=order_id, request=requests[index], info=info,
|
|
853
|
+
account=requests[index].account or self.account,
|
|
854
|
+
)
|
|
855
|
+
# Refused over Synpath's default builder code: signed again without it and sent once more.
|
|
856
|
+
for index, again in zip(retry, await self.create_orders([requests[i] for i in retry]) if retry else []):
|
|
857
|
+
results[index] = again
|
|
858
|
+
return results
|
|
859
|
+
|
|
860
|
+
async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order:
|
|
861
|
+
"""Cancel one order and read it back. An order that had already
|
|
862
|
+
matched comes back as it is, with the venue's reason in `info`."""
|
|
863
|
+
raw = await self._private("DELETE", "/order", body={"orderID": order_id}, kind="write", priority=Priority.HIGH)
|
|
864
|
+
refused = ((raw or {}).get("not_canceled") or {}).get(order_id)
|
|
865
|
+
order = await self.fetch_order(order_id)
|
|
866
|
+
if refused and not order.is_terminal:
|
|
867
|
+
raise OrderRejected(f"polymarket: cancel of {order_id} refused: {refused}", reason="cancel_refused", info=raw or {})
|
|
868
|
+
if order_id in ((raw or {}).get("canceled") or []) and order.status == OrderStatus.OPEN:
|
|
869
|
+
order = order.model_copy(update={"status": OrderStatus.CANCELED, "remaining": Decimal("0")})
|
|
870
|
+
return order.model_copy(update={"info": {**order.info, "cancel": raw}})
|
|
871
|
+
|
|
872
|
+
async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]:
|
|
873
|
+
"""Cancel many by id, a thousand to a request. Each result is the
|
|
874
|
+
venue's verdict on that id and the orders are not read back, so a
|
|
875
|
+
cancelled one carries its id and status only: read it with
|
|
876
|
+
`fetch_order` for what filled first."""
|
|
877
|
+
results: dict[str, Order | Exception] = {}
|
|
878
|
+
for start in range(0, len(order_ids), CANCEL_BATCH):
|
|
879
|
+
chunk = order_ids[start:start + CANCEL_BATCH]
|
|
880
|
+
raw = await self._private("DELETE", "/orders", body=chunk, kind="write", priority=Priority.HIGH)
|
|
881
|
+
canceled = set((raw or {}).get("canceled") or [])
|
|
882
|
+
refused = (raw or {}).get("not_canceled") or {}
|
|
883
|
+
for oid in chunk:
|
|
884
|
+
if oid in canceled:
|
|
885
|
+
results[oid] = Order(
|
|
886
|
+
id=oid, venue=VENUE, account=self.account, market_id=market_id or "",
|
|
887
|
+
side=Side.BUY, type=OrderType.LIMIT, time_in_force=TimeInForce.GTC,
|
|
888
|
+
status=OrderStatus.CANCELED, amount=Decimal("0"), info={"cancel": "canceled"},
|
|
889
|
+
)
|
|
890
|
+
else:
|
|
891
|
+
results[oid] = OrderRejected(
|
|
892
|
+
f"polymarket: cancel of {oid} refused: {refused.get(oid, 'not reported')}",
|
|
893
|
+
reason="cancel_refused", info={"reason": refused.get(oid)},
|
|
894
|
+
)
|
|
895
|
+
return [results[oid] for oid in order_ids]
|
|
896
|
+
|
|
897
|
+
async def cancel_all_orders(self, *, market_id: str | None = None) -> int:
|
|
898
|
+
"""Every resting order in the account, or on one market. Returns how
|
|
899
|
+
many the venue cancelled."""
|
|
900
|
+
if market_id:
|
|
901
|
+
body = {"market": (await self.market_tokens(market_id)).condition_id}
|
|
902
|
+
raw = await self._private("DELETE", "/cancel-market-orders", body=body, kind="write", priority=Priority.HIGH)
|
|
903
|
+
else:
|
|
904
|
+
raw = await self._private("DELETE", "/cancel-all", kind="write", priority=Priority.HIGH)
|
|
905
|
+
return len((raw or {}).get("canceled") or [])
|
|
906
|
+
|
|
907
|
+
async def fetch_order(self, order_id: str) -> Order:
|
|
908
|
+
raw = await self._private("GET", f"/data/order/{order_id}")
|
|
909
|
+
if not raw or not isinstance(raw, dict) or not raw.get("id"):
|
|
910
|
+
raise OrderNotFound(f"polymarket: no order {order_id}")
|
|
911
|
+
return await self._order_of(raw)
|
|
912
|
+
|
|
913
|
+
async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]:
|
|
914
|
+
orders: list[Order] = []
|
|
915
|
+
cursor = INITIAL_CURSOR
|
|
916
|
+
condition = (await self.market_tokens(market_id)).condition_id if market_id else None
|
|
917
|
+
while cursor and cursor != END_CURSOR:
|
|
918
|
+
raw = await self._private("GET", "/data/orders", params={
|
|
919
|
+
"market": condition, "next_cursor": cursor,
|
|
920
|
+
})
|
|
921
|
+
for row in (raw or {}).get("data") or []:
|
|
922
|
+
orders.append(await self._order_of(row))
|
|
923
|
+
cursor = (raw or {}).get("next_cursor")
|
|
924
|
+
return orders
|
|
925
|
+
|
|
926
|
+
async def fetch_my_trades(
|
|
927
|
+
self, *, market_id: str | None = None, order_id: str | None = None,
|
|
928
|
+
since: int | None = None, limit: int | None = None, cursor: str | None = None,
|
|
929
|
+
) -> Page[Fill]:
|
|
930
|
+
"""This account's fills, one page. `order_id` narrows the page to
|
|
931
|
+
fills of that order; the venue itself filters by market."""
|
|
932
|
+
condition = (await self.market_tokens(market_id)).condition_id if market_id else None
|
|
933
|
+
raw = await self._private("GET", "/data/trades", params={
|
|
934
|
+
"market": condition,
|
|
935
|
+
"after": int(since / 1000) if since else None,
|
|
936
|
+
"next_cursor": cursor or INITIAL_CURSOR,
|
|
937
|
+
})
|
|
938
|
+
wallets = {self.wallet, self.signer.address}
|
|
939
|
+
fills: list[Fill] = []
|
|
940
|
+
for row in (raw or {}).get("data") or []:
|
|
941
|
+
tokens = await self.locate_token(str(row.get("asset_id") or ""))
|
|
942
|
+
fills.extend(fills_of(
|
|
943
|
+
row, api_key=self._api_key or "", wallets=wallets, market_id=tokens.market_id,
|
|
944
|
+
outcome_of=tokens.outcome_of, account=self.account,
|
|
945
|
+
))
|
|
946
|
+
if order_id:
|
|
947
|
+
fills = [f for f in fills if f.order_id == order_id]
|
|
948
|
+
nxt = (raw or {}).get("next_cursor")
|
|
949
|
+
return Page(fills[:limit] if limit else fills, next_cursor=nxt if nxt and nxt != END_CURSOR else None)
|
|
950
|
+
|
|
951
|
+
# -- account --------------------------------------------------------------
|
|
952
|
+
|
|
953
|
+
async def fetch_balance(self, *, account: Account | None = None) -> Balance:
|
|
954
|
+
"""The account wallet's pUSD, and in `info` the allowance each
|
|
955
|
+
exchange has. The venue reports the wallet balance; open orders
|
|
956
|
+
reserve part of it, which it does not report as a figure."""
|
|
957
|
+
raw = await self._private("GET", "/balance-allowance", params={
|
|
958
|
+
"asset_type": "COLLATERAL", "signature_type": self.credentials.signature_type,
|
|
959
|
+
})
|
|
960
|
+
balance = from_poly_raw_amount((raw or {}).get("balance") or 0)
|
|
961
|
+
return Balance(
|
|
962
|
+
venue=VENUE, account=account or self.account, currency="pUSD",
|
|
963
|
+
total=balance, available=balance, locked=None, buying_power=None,
|
|
964
|
+
timestamp=int(time.time() * 1000), info=raw or {},
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]:
|
|
968
|
+
"""Token inventories held by the account wallet, from the Data API.
|
|
969
|
+
Resolved markets whose tokens are still held come back with
|
|
970
|
+
`resolved` and, if they pay, `redeemable`."""
|
|
971
|
+
rows: list[dict[str, Any]] = []
|
|
972
|
+
cursor: str | None = None
|
|
973
|
+
condition = (await self.market_tokens(market_id)).condition_id if market_id else None
|
|
974
|
+
while True:
|
|
975
|
+
await self.limiter.acquire(cost=1, kind="read")
|
|
976
|
+
raw = await self.data.get("/v2/positions", {
|
|
977
|
+
"user": self.wallet, "condition": condition,
|
|
978
|
+
"event_id": ids.native(VENUE, event_id) if event_id else None,
|
|
979
|
+
"limit": None if cursor else 500, "cursor": cursor,
|
|
980
|
+
})
|
|
981
|
+
rows.extend((raw or {}).get("data") or [])
|
|
982
|
+
cursor = ((raw or {}).get("pagination") or {}).get("next_cursor")
|
|
983
|
+
if not cursor:
|
|
984
|
+
break
|
|
985
|
+
by_market: dict[str, list[dict[str, Any]]] = {}
|
|
986
|
+
for row in rows:
|
|
987
|
+
tokens = await self.locate_token(str(row.get("token_id") or ""))
|
|
988
|
+
by_market.setdefault(tokens.gamma_id, []).append(row)
|
|
989
|
+
positions = [
|
|
990
|
+
position_of(group, market_id=self._markets[gamma].market_id, outcome_of=self._markets[gamma].outcome_of, account=self.account)
|
|
991
|
+
for gamma, group in by_market.items()
|
|
992
|
+
]
|
|
993
|
+
return [p for p in positions if p.inventory_yes or p.inventory_no]
|
|
994
|
+
|
|
995
|
+
async def fetch_settlements(
|
|
996
|
+
self, *, market_id: str | None = None, since: int | None = None,
|
|
997
|
+
limit: int | None = None, cursor: str | None = None,
|
|
998
|
+
) -> Page:
|
|
999
|
+
"""Redemptions, one row per outcome redeemed (the Data API reports a
|
|
1000
|
+
two-sided redeem as two rows, the losing one paying 0)."""
|
|
1001
|
+
from .types import Settlement
|
|
1002
|
+
|
|
1003
|
+
await self.limiter.acquire(cost=1, kind="read")
|
|
1004
|
+
raw = await self.data.get("/v2/activity", {
|
|
1005
|
+
"user": self.wallet, "type": "REDEEM",
|
|
1006
|
+
"condition": (await self.market_tokens(market_id)).condition_id if market_id else None,
|
|
1007
|
+
"start": int(since / 1000) if since else None, "limit": limit, "cursor": cursor,
|
|
1008
|
+
})
|
|
1009
|
+
rows = (raw or {}).get("data") or []
|
|
1010
|
+
settlements = []
|
|
1011
|
+
for row in rows:
|
|
1012
|
+
payout = D(row.get("usdc_size") or 0)
|
|
1013
|
+
token = str(row.get("token_id") or "")
|
|
1014
|
+
tokens = await self.locate_token(token) if token else None
|
|
1015
|
+
held = tokens.outcome_of(token) if tokens else None
|
|
1016
|
+
settlements.append(Settlement(
|
|
1017
|
+
venue=VENUE, account=self.account,
|
|
1018
|
+
market_id=tokens.market_id if tokens else ids.qualify(VENUE, str(row.get("condition_id") or "")),
|
|
1019
|
+
held=PositionSide.LONG if held == "yes" else PositionSide.SHORT if held == "no" else None,
|
|
1020
|
+
result=None, won=payout > 0,
|
|
1021
|
+
amount=D(row.get("size") or 0), cost=None, payout=payout, pnl=None,
|
|
1022
|
+
timestamp=int(row["timestamp"]) * 1000 if row.get("timestamp") else None, info=row,
|
|
1023
|
+
))
|
|
1024
|
+
return Page(settlements, next_cursor=((raw or {}).get("pagination") or {}).get("next_cursor"))
|
|
1025
|
+
|
|
1026
|
+
async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, amount: Decimal) -> FeeEstimate:
|
|
1027
|
+
"""The market's taker fee at the YES `price` for `amount` shares, from
|
|
1028
|
+
the CLOB's own market parameters. Makers pay nothing. The fee is
|
|
1029
|
+
symmetric in the YES price, so `side` does not change it."""
|
|
1030
|
+
tokens = await self.market_tokens(market_id)
|
|
1031
|
+
await self.limiter.acquire(cost=1, kind="read")
|
|
1032
|
+
raw = await self.clob.get(f"/clob-markets/{tokens.condition_id}")
|
|
1033
|
+
fd = (raw or {}).get("fd") or {}
|
|
1034
|
+
rate = fd.get("r")
|
|
1035
|
+
schedule = FeeSchedule(
|
|
1036
|
+
venue=VENUE, scope="market", scope_id=tokens.condition_id, fee_type="quadratic_theta",
|
|
1037
|
+
taker_rate=float(rate) if rate is not None else 0.0, maker_rate=0.0,
|
|
1038
|
+
exponent=float(fd["e"]) if fd.get("e") is not None else None, info={"fd": fd},
|
|
1039
|
+
)
|
|
1040
|
+
taker = schedule.estimate(float(price), float(amount), taker=True)
|
|
1041
|
+
return FeeEstimate(
|
|
1042
|
+
venue=VENUE, market_id=tokens.market_id, side=side, price=D(price), amount=D(amount),
|
|
1043
|
+
taker_fee=D(str(taker)) if taker is not None else None, maker_fee=Decimal("0"),
|
|
1044
|
+
currency="pUSD", info={"schedule": schedule.model_dump()},
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
async def closed_only(self) -> bool:
|
|
1048
|
+
"""Whether the account may only reduce positions right now."""
|
|
1049
|
+
raw = await self._private("GET", "/auth/ban-status/closed-only")
|
|
1050
|
+
return bool((raw or {}).get("closed_only"))
|
|
1051
|
+
|
|
1052
|
+
# -- heartbeat ------------------------------------------------------------
|
|
1053
|
+
|
|
1054
|
+
async def send_heartbeat(self) -> str:
|
|
1055
|
+
"""One heartbeat. Once the first is accepted the venue cancels every
|
|
1056
|
+
order on these API credentials if ten seconds pass without another."""
|
|
1057
|
+
try:
|
|
1058
|
+
raw = await self._private(
|
|
1059
|
+
"POST", "/v1/heartbeats", body={"heartbeat_id": self._heartbeat_id},
|
|
1060
|
+
kind="write", priority=Priority.HIGH,
|
|
1061
|
+
)
|
|
1062
|
+
except ExchangeError as exc:
|
|
1063
|
+
expected = exc.body.get("heartbeat_id") if isinstance(exc.body, dict) else None
|
|
1064
|
+
if not expected:
|
|
1065
|
+
raise
|
|
1066
|
+
self._heartbeat_id = expected
|
|
1067
|
+
raw = await self._private(
|
|
1068
|
+
"POST", "/v1/heartbeats", body={"heartbeat_id": self._heartbeat_id},
|
|
1069
|
+
kind="write", priority=Priority.HIGH,
|
|
1070
|
+
)
|
|
1071
|
+
self._heartbeat_id = str((raw or {}).get("heartbeat_id") or "")
|
|
1072
|
+
return self._heartbeat_id
|
|
1073
|
+
|
|
1074
|
+
def start_heartbeat(self, interval_s: float = 5.0) -> asyncio.Task:
|
|
1075
|
+
"""Keep the dead man's switch fed from a task on this loop.
|
|
1076
|
+
|
|
1077
|
+
If the process stalls or dies, the venue cancels its resting orders
|
|
1078
|
+
within fifteen seconds. A heartbeat that fails is recorded in
|
|
1079
|
+
`heartbeat_error` and retried on the next beat; it is not swallowed
|
|
1080
|
+
silently, and it is not allowed to kill the loop.
|
|
1081
|
+
"""
|
|
1082
|
+
if self._heartbeat_task and not self._heartbeat_task.done():
|
|
1083
|
+
return self._heartbeat_task
|
|
1084
|
+
|
|
1085
|
+
async def beat() -> None:
|
|
1086
|
+
while True:
|
|
1087
|
+
try:
|
|
1088
|
+
await self.send_heartbeat()
|
|
1089
|
+
self.heartbeat_error = None
|
|
1090
|
+
except asyncio.CancelledError:
|
|
1091
|
+
raise
|
|
1092
|
+
except Exception as exc: # recorded, surfaced, retried
|
|
1093
|
+
self.heartbeat_error = exc
|
|
1094
|
+
await asyncio.sleep(interval_s)
|
|
1095
|
+
|
|
1096
|
+
self._heartbeat_task = asyncio.get_running_loop().create_task(beat())
|
|
1097
|
+
return self._heartbeat_task
|
|
1098
|
+
|
|
1099
|
+
async def stop_heartbeat(self) -> None:
|
|
1100
|
+
task, self._heartbeat_task = self._heartbeat_task, None
|
|
1101
|
+
if task:
|
|
1102
|
+
task.cancel()
|
|
1103
|
+
try:
|
|
1104
|
+
await task
|
|
1105
|
+
except asyncio.CancelledError:
|
|
1106
|
+
pass
|
|
1107
|
+
|
|
1108
|
+
# -- wallet operations ----------------------------------------------------
|
|
1109
|
+
|
|
1110
|
+
async def fetch_allowances(self, *, token_id: str | None = None) -> dict[str, Decimal]:
|
|
1111
|
+
"""Spender -> allowance, as the CLOB's cache sees it: pUSD by default,
|
|
1112
|
+
a token's operators with `token_id`."""
|
|
1113
|
+
raw = await self._private("GET", "/balance-allowance", params={
|
|
1114
|
+
"asset_type": "CONDITIONAL" if token_id else "COLLATERAL", "token_id": token_id,
|
|
1115
|
+
"signature_type": self.credentials.signature_type,
|
|
1116
|
+
})
|
|
1117
|
+
return {spender: from_poly_raw_amount(value) for spender, value in ((raw or {}).get("allowances") or {}).items()}
|
|
1118
|
+
|
|
1119
|
+
async def check_approvals(self) -> dict[str, bool]:
|
|
1120
|
+
"""Whether each exchange may spend the wallet's pUSD. Missing ones
|
|
1121
|
+
are what `approve_trading` fixes."""
|
|
1122
|
+
allowances = {k.lower(): v for k, v in (await self.fetch_allowances()).items()}
|
|
1123
|
+
return {spender: allowances.get(spender.lower(), Decimal("0")) > 0 for spender in (sig.EXCHANGE, sig.NEG_RISK_EXCHANGE)}
|
|
1124
|
+
|
|
1125
|
+
async def sync_allowances(self, *, token_id: str | None = None) -> None:
|
|
1126
|
+
"""Tell the CLOB to re-read on-chain balances and allowances, after an
|
|
1127
|
+
approval or a split changed them."""
|
|
1128
|
+
await self._private("GET", "/balance-allowance/update", params={
|
|
1129
|
+
"asset_type": "CONDITIONAL" if token_id else "COLLATERAL", "token_id": token_id,
|
|
1130
|
+
"signature_type": self.credentials.signature_type,
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
async def approve_trading(self, *, include_adapters: bool = True) -> dict[str, Any]:
|
|
1134
|
+
"""Grant every approval trading needs, in one wallet transaction.
|
|
1135
|
+
|
|
1136
|
+
pUSD and the conditional tokens for both exchanges, and with
|
|
1137
|
+
`include_adapters` for both collateral adapters that split, merge and
|
|
1138
|
+
redeem use. Idempotent: an approval already granted is granted again.
|
|
1139
|
+
"""
|
|
1140
|
+
spenders = [sig.EXCHANGE, sig.NEG_RISK_EXCHANGE]
|
|
1141
|
+
if include_adapters:
|
|
1142
|
+
spenders += [sig.CTF_COLLATERAL_ADAPTER, sig.NEG_RISK_CTF_COLLATERAL_ADAPTER]
|
|
1143
|
+
calls = sig.trading_approval_calls(spenders)
|
|
1144
|
+
result = await self.execute_wallet_calls(calls, description="Set trading approvals")
|
|
1145
|
+
await self.sync_allowances()
|
|
1146
|
+
return result
|
|
1147
|
+
|
|
1148
|
+
async def split(self, market_id: str, amount: Decimal, *, neg_risk: bool | None = None) -> dict[str, Any]:
|
|
1149
|
+
"""pUSD into a full set: `amount` YES and `amount` NO tokens."""
|
|
1150
|
+
tokens = await self.market_tokens(market_id)
|
|
1151
|
+
neg_risk = tokens.neg_risk if neg_risk is None else neg_risk
|
|
1152
|
+
call = {"target": sig.collateral_adapter_for(neg_risk), "value": "0",
|
|
1153
|
+
"data": sig.split_calldata(tokens.condition_id, sig.to_raw(D(amount)))}
|
|
1154
|
+
return await self.execute_wallet_calls([call], description="Split position")
|
|
1155
|
+
|
|
1156
|
+
async def merge(self, market_id: str, amount: Decimal, *, neg_risk: bool | None = None) -> dict[str, Any]:
|
|
1157
|
+
"""A full set back into pUSD: `amount` of each outcome."""
|
|
1158
|
+
tokens = await self.market_tokens(market_id)
|
|
1159
|
+
neg_risk = tokens.neg_risk if neg_risk is None else neg_risk
|
|
1160
|
+
call = {"target": sig.collateral_adapter_for(neg_risk), "value": "0",
|
|
1161
|
+
"data": sig.merge_calldata(tokens.condition_id, sig.to_raw(D(amount)))}
|
|
1162
|
+
return await self.execute_wallet_calls([call], description="Merge positions")
|
|
1163
|
+
|
|
1164
|
+
async def redeem(self, market_id: str, *, neg_risk: bool | None = None) -> dict[str, Any]:
|
|
1165
|
+
"""Every token of a resolved market into pUSD; losing tokens pay 0."""
|
|
1166
|
+
tokens = await self.market_tokens(market_id)
|
|
1167
|
+
neg_risk = tokens.neg_risk if neg_risk is None else neg_risk
|
|
1168
|
+
call = {"target": sig.collateral_adapter_for(neg_risk), "value": "0", "data": sig.redeem_calldata(tokens.condition_id)}
|
|
1169
|
+
return await self.execute_wallet_calls([call], description="Redeem positions")
|
|
1170
|
+
|
|
1171
|
+
async def execute_wallet_calls(
|
|
1172
|
+
self, calls: list[dict[str, str]], *, description: str, deadline_s: int = 600,
|
|
1173
|
+
wait: bool = True, timeout_s: float = 120.0, poll_interval_s: float = 1.0,
|
|
1174
|
+
) -> dict[str, Any]:
|
|
1175
|
+
"""Run contract calls from the account wallet.
|
|
1176
|
+
|
|
1177
|
+
A Deposit Wallet signs one `Batch` and the relayer submits it
|
|
1178
|
+
gaslessly (a Relayer API key is required); an EOA sends each call as
|
|
1179
|
+
its own Polygon transaction (an RPC endpoint and POL for gas are
|
|
1180
|
+
required). Legacy proxy and Safe wallets are refused: their relayer
|
|
1181
|
+
payloads differ and are not built here.
|
|
1182
|
+
"""
|
|
1183
|
+
kind = self.credentials.signature_type
|
|
1184
|
+
if kind == sig.DEPOSIT_WALLET:
|
|
1185
|
+
return await self._relay_batch(
|
|
1186
|
+
calls, description=description, deadline_s=deadline_s, wait=wait, timeout_s=timeout_s, poll_s=poll_interval_s,
|
|
1187
|
+
)
|
|
1188
|
+
if kind == sig.EOA:
|
|
1189
|
+
return await self._send_transactions(calls, wait=wait, timeout_s=timeout_s, poll_s=poll_interval_s)
|
|
1190
|
+
raise NotSupported(
|
|
1191
|
+
"polymarket: wallet transactions for legacy proxy (1) and Safe (2) wallets are not built; "
|
|
1192
|
+
"use polymarket.com or the venue SDK for this wallet"
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
def _own_relayer_headers(self) -> dict[str, str] | None:
|
|
1196
|
+
"""The account's own Relayer API key, when it has one."""
|
|
1197
|
+
creds = self.credentials
|
|
1198
|
+
if not creds.relayer_api_key or not creds.relayer_api_key_address:
|
|
1199
|
+
return None
|
|
1200
|
+
return {"RELAYER_API_KEY": creds.relayer_api_key, "RELAYER_API_KEY_ADDRESS": creds.relayer_api_key_address}
|
|
1201
|
+
|
|
1202
|
+
async def _builder_headers(self, method: str, path: str, body: str = "") -> tuple[dict[str, str] | None, str]:
|
|
1203
|
+
"""Builder headers for one relayer request from Synpath's signer, and
|
|
1204
|
+
why not when it gives none (off, unreachable, out of quota)."""
|
|
1205
|
+
if not self.builder_signer_url:
|
|
1206
|
+
return None, "Synpath's builder signer is turned off"
|
|
1207
|
+
try:
|
|
1208
|
+
response = await self._http.post(
|
|
1209
|
+
self.builder_signer_url, json={"method": method, "path": path, "body": body}, timeout=10.0,
|
|
1210
|
+
)
|
|
1211
|
+
except Exception as exc: # noqa: BLE001 -- any failure here means "use the account's own key"
|
|
1212
|
+
return None, f"Synpath's builder signer is unreachable ({type(exc).__name__})"
|
|
1213
|
+
if response.status_code != 200:
|
|
1214
|
+
try:
|
|
1215
|
+
reason = str(response.json().get("detail") or response.status_code)
|
|
1216
|
+
except ValueError:
|
|
1217
|
+
reason = str(response.status_code)
|
|
1218
|
+
return None, f"Synpath's builder signer declined: {reason}"
|
|
1219
|
+
headers = (response.json() or {}).get("headers")
|
|
1220
|
+
if not isinstance(headers, dict) or not headers.get("POLY_BUILDER_SIGNATURE"):
|
|
1221
|
+
return None, "Synpath's builder signer answered without headers"
|
|
1222
|
+
return {str(k): str(v) for k, v in headers.items()}, ""
|
|
1223
|
+
|
|
1224
|
+
def _no_relayer_access(self, reason: str) -> NotSupported:
|
|
1225
|
+
return NotSupported(
|
|
1226
|
+
f"polymarket: gasless wallet transactions go through Polymarket's relayer. {reason}, and this account "
|
|
1227
|
+
f"has no Relayer API key of its own: set POLYMARKET_RELAYER_API_KEY and POLYMARKET_RELAYER_API_KEY_ADDRESS "
|
|
1228
|
+
f"(polymarket.com -> Settings -> API Keys -> Relayer API Keys)"
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
async def _relay_batch(
|
|
1232
|
+
self, calls: list[dict[str, str]], *, description: str, deadline_s: int, wait: bool, timeout_s: float,
|
|
1233
|
+
poll_s: float,
|
|
1234
|
+
) -> dict[str, Any]:
|
|
1235
|
+
own = self._own_relayer_headers()
|
|
1236
|
+
lookup = own
|
|
1237
|
+
if lookup is None:
|
|
1238
|
+
lookup, _ = await self._builder_headers("GET", "/v1/account/transactions/params")
|
|
1239
|
+
await self.limiter.acquire(cost=1, kind="read")
|
|
1240
|
+
params = await self.relayer.get(
|
|
1241
|
+
"/v1/account/transactions/params", {"address": self.signer.address, "type": "WALLET"}, headers=lookup or {},
|
|
1242
|
+
)
|
|
1243
|
+
nonce = int((params or {}).get("nonce") or 0)
|
|
1244
|
+
deadline = self._now_s() + deadline_s
|
|
1245
|
+
signature = self.signer.sign_typed_data(sig.wallet_batch_typed_data(self.wallet, nonce, deadline, calls))
|
|
1246
|
+
body = {
|
|
1247
|
+
"type": "WALLET",
|
|
1248
|
+
"from": self.signer.address,
|
|
1249
|
+
"to": sig.DEPOSIT_WALLET_FACTORY,
|
|
1250
|
+
"nonce": str(nonce),
|
|
1251
|
+
"signature": signature,
|
|
1252
|
+
"metadata": description,
|
|
1253
|
+
"depositWalletParams": {"depositWallet": self.wallet, "deadline": str(deadline), "calls": calls},
|
|
1254
|
+
}
|
|
1255
|
+
text = serialize(body)
|
|
1256
|
+
# Synpath's builder credentials first, so the transaction is attributed
|
|
1257
|
+
# to Synpath and needs no key of the account's own; the account's own
|
|
1258
|
+
# Relayer API key when the signer gives none or the relayer refuses it.
|
|
1259
|
+
headers, reason = await self._builder_headers("POST", "/submit", text)
|
|
1260
|
+
if headers is None and own is None:
|
|
1261
|
+
raise self._no_relayer_access(reason)
|
|
1262
|
+
await self.limiter.acquire(cost=1, kind="write")
|
|
1263
|
+
try:
|
|
1264
|
+
submitted = await self._submit(text, headers or own or {})
|
|
1265
|
+
except (RateLimitExceeded, AuthenticationError) as exc:
|
|
1266
|
+
if headers is None or own is None:
|
|
1267
|
+
if headers is not None:
|
|
1268
|
+
raise self._no_relayer_access(f"Polymarket's relayer refused Synpath's builder access ({exc})") from None
|
|
1269
|
+
raise
|
|
1270
|
+
log.warning("polymarket: the relayer refused Synpath's builder access (%s); using the account's own key", exc)
|
|
1271
|
+
submitted = await self._submit(text, own)
|
|
1272
|
+
transaction_id = str((submitted or {}).get("transactionID") or "")
|
|
1273
|
+
if not wait:
|
|
1274
|
+
return {"transaction_id": transaction_id, "state": (submitted or {}).get("state")}
|
|
1275
|
+
return await self._await_relayed(transaction_id, headers=own or {}, timeout_s=timeout_s, poll_s=poll_s)
|
|
1276
|
+
|
|
1277
|
+
async def _submit(self, text: str, headers: dict[str, str]) -> Any:
|
|
1278
|
+
"""`POST /submit` with the exact text the headers were signed over."""
|
|
1279
|
+
return await self.relayer.request(
|
|
1280
|
+
"POST", "/submit", content=text, headers={**headers, "Content-Type": "application/json"},
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
async def _await_relayed(
|
|
1284
|
+
self, transaction_id: str, *, headers: dict[str, str], timeout_s: float, poll_s: float,
|
|
1285
|
+
) -> dict[str, Any]:
|
|
1286
|
+
loop = asyncio.get_running_loop()
|
|
1287
|
+
stop = loop.time() + timeout_s
|
|
1288
|
+
delay = poll_s
|
|
1289
|
+
while True:
|
|
1290
|
+
await self.limiter.acquire(cost=1, kind="read")
|
|
1291
|
+
raw = await self.relayer.get(f"/v1/account/transactions/{transaction_id}", headers=headers)
|
|
1292
|
+
state = str((raw or {}).get("state") or "")
|
|
1293
|
+
if state == "STATE_CONFIRMED":
|
|
1294
|
+
return raw
|
|
1295
|
+
if state in ("STATE_FAILED", "STATE_INVALID"):
|
|
1296
|
+
raise OrderRejected(
|
|
1297
|
+
f"polymarket: wallet transaction {transaction_id} {state}: {(raw or {}).get('error_msg')}",
|
|
1298
|
+
reason=state.lower(), info=raw or {},
|
|
1299
|
+
)
|
|
1300
|
+
if loop.time() >= stop:
|
|
1301
|
+
raise ExchangeNotAvailable(
|
|
1302
|
+
f"polymarket: wallet transaction {transaction_id} still {state or 'unknown'} after {timeout_s}s; "
|
|
1303
|
+
f"it may yet confirm", body=raw,
|
|
1304
|
+
)
|
|
1305
|
+
await asyncio.sleep(delay)
|
|
1306
|
+
delay = min(delay * 1.5, 5.0)
|
|
1307
|
+
|
|
1308
|
+
async def _rpc(self, method: str, params: list[Any]) -> Any:
|
|
1309
|
+
if not self.credentials.rpc_url:
|
|
1310
|
+
raise NotSupported("polymarket: an EOA's transactions need a Polygon RPC endpoint; set POLYMARKET_RPC_URL")
|
|
1311
|
+
raw = await self.clob.request(
|
|
1312
|
+
"POST", self.credentials.rpc_url, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
|
|
1313
|
+
)
|
|
1314
|
+
if isinstance(raw, dict) and raw.get("error"):
|
|
1315
|
+
raise ExchangeError(f"polygon rpc {method}: {raw['error']}", body=raw)
|
|
1316
|
+
return (raw or {}).get("result")
|
|
1317
|
+
|
|
1318
|
+
async def _send_transactions(
|
|
1319
|
+
self, calls: list[dict[str, str]], *, wait: bool, timeout_s: float, poll_s: float,
|
|
1320
|
+
) -> dict[str, Any]:
|
|
1321
|
+
address = self.signer.address
|
|
1322
|
+
nonce = int(await self._rpc("eth_getTransactionCount", [address, "pending"]), 16)
|
|
1323
|
+
gas_price = int(await self._rpc("eth_gasPrice", []), 16)
|
|
1324
|
+
priority_fee = int(await self._rpc("eth_maxPriorityFeePerGas", []), 16)
|
|
1325
|
+
hashes = []
|
|
1326
|
+
for offset, call in enumerate(calls):
|
|
1327
|
+
estimate = int(await self._rpc("eth_estimateGas", [{"from": address, "to": call["target"], "data": call["data"]}]), 16)
|
|
1328
|
+
tx = {
|
|
1329
|
+
"chainId": sig.CHAIN_ID, "nonce": nonce + offset, "to": call["target"], "value": int(call["value"]),
|
|
1330
|
+
"data": call["data"], "gas": int(estimate * 1.2),
|
|
1331
|
+
"maxFeePerGas": gas_price * 2 + priority_fee, "maxPriorityFeePerGas": priority_fee, "type": 2,
|
|
1332
|
+
}
|
|
1333
|
+
hashes.append(await self._rpc("eth_sendRawTransaction", [self.signer.sign_transaction(tx)]))
|
|
1334
|
+
if not wait:
|
|
1335
|
+
return {"transaction_hashes": hashes}
|
|
1336
|
+
loop = asyncio.get_running_loop()
|
|
1337
|
+
stop = loop.time() + timeout_s
|
|
1338
|
+
receipts = []
|
|
1339
|
+
for tx_hash in hashes:
|
|
1340
|
+
while True:
|
|
1341
|
+
receipt = await self._rpc("eth_getTransactionReceipt", [tx_hash])
|
|
1342
|
+
if receipt:
|
|
1343
|
+
if int(receipt.get("status", "0x0"), 16) != 1:
|
|
1344
|
+
raise OrderRejected(f"polymarket: transaction {tx_hash} reverted", reason="reverted", info=receipt)
|
|
1345
|
+
receipts.append(receipt)
|
|
1346
|
+
break
|
|
1347
|
+
if loop.time() >= stop:
|
|
1348
|
+
raise ExchangeNotAvailable(f"polymarket: transaction {tx_hash} unmined after {timeout_s}s")
|
|
1349
|
+
await asyncio.sleep(poll_s)
|
|
1350
|
+
return {"transaction_hashes": hashes, "receipts": receipts}
|
|
1351
|
+
|
|
1352
|
+
async def close(self) -> None:
|
|
1353
|
+
await self.stop_heartbeat()
|
|
1354
|
+
await self._http.aclose()
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def _error_text(body: Any) -> str | None:
|
|
1358
|
+
if isinstance(body, dict):
|
|
1359
|
+
for key in ("error", "errorMsg", "error_msg", "message"):
|
|
1360
|
+
if body.get(key):
|
|
1361
|
+
return str(body[key])
|
|
1362
|
+
return None
|