polytrader 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.
- polytrader/__init__.py +85 -0
- polytrader/client.py +554 -0
- polytrader/constants.py +16 -0
- polytrader/models.py +735 -0
- polytrader/rpc.py +190 -0
- polytrader/websocket.py +326 -0
- polytrader-0.1.0.dist-info/METADATA +20 -0
- polytrader-0.1.0.dist-info/RECORD +10 -0
- polytrader-0.1.0.dist-info/WHEEL +4 -0
- polytrader-0.1.0.dist-info/licenses/LICENSE +21 -0
polytrader/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from polytrader.client import PolyTrader
|
|
2
|
+
from polytrader.models import (
|
|
3
|
+
Balance,
|
|
4
|
+
BestBidAsk,
|
|
5
|
+
Book,
|
|
6
|
+
Coin,
|
|
7
|
+
EventMessage,
|
|
8
|
+
LastTradePrice,
|
|
9
|
+
MakerOrder,
|
|
10
|
+
MarketEventType,
|
|
11
|
+
MarketResolved,
|
|
12
|
+
NewMarket,
|
|
13
|
+
OrderBookLevel,
|
|
14
|
+
OrderResult,
|
|
15
|
+
OrderResultStatus,
|
|
16
|
+
OrderSide,
|
|
17
|
+
OrderStatus,
|
|
18
|
+
OrderType,
|
|
19
|
+
Outcome,
|
|
20
|
+
PolymarketAuth,
|
|
21
|
+
PolymarketOrder,
|
|
22
|
+
PolymarketOrderType,
|
|
23
|
+
PolymarketPosition,
|
|
24
|
+
PolymarketTrade,
|
|
25
|
+
PriceChange,
|
|
26
|
+
PriceChangeItem,
|
|
27
|
+
TickSizeChange,
|
|
28
|
+
Timeframe,
|
|
29
|
+
TokenIdPair,
|
|
30
|
+
TraderSide,
|
|
31
|
+
TradeStatus,
|
|
32
|
+
UpDownMarket,
|
|
33
|
+
UpDownMarketToken,
|
|
34
|
+
UserEventType,
|
|
35
|
+
UserOrder,
|
|
36
|
+
UserTrade,
|
|
37
|
+
crypto_fee,
|
|
38
|
+
)
|
|
39
|
+
from polytrader.websocket import (
|
|
40
|
+
BasePolymarketWebSocket,
|
|
41
|
+
PolymarketMarketWebSocket,
|
|
42
|
+
PolymarketUserWebSocket,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"Balance",
|
|
47
|
+
"BasePolymarketWebSocket",
|
|
48
|
+
"BestBidAsk",
|
|
49
|
+
"Book",
|
|
50
|
+
"Coin",
|
|
51
|
+
"EventMessage",
|
|
52
|
+
"LastTradePrice",
|
|
53
|
+
"MakerOrder",
|
|
54
|
+
"MarketEventType",
|
|
55
|
+
"MarketResolved",
|
|
56
|
+
"NewMarket",
|
|
57
|
+
"OrderBookLevel",
|
|
58
|
+
"OrderResult",
|
|
59
|
+
"OrderResultStatus",
|
|
60
|
+
"OrderSide",
|
|
61
|
+
"OrderStatus",
|
|
62
|
+
"OrderType",
|
|
63
|
+
"Outcome",
|
|
64
|
+
"PolymarketAuth",
|
|
65
|
+
"PolymarketOrder",
|
|
66
|
+
"PolymarketOrderType",
|
|
67
|
+
"PolymarketPosition",
|
|
68
|
+
"PolymarketTrade",
|
|
69
|
+
"PolyTrader",
|
|
70
|
+
"PolymarketMarketWebSocket",
|
|
71
|
+
"PolymarketUserWebSocket",
|
|
72
|
+
"PriceChange",
|
|
73
|
+
"PriceChangeItem",
|
|
74
|
+
"TickSizeChange",
|
|
75
|
+
"Timeframe",
|
|
76
|
+
"TokenIdPair",
|
|
77
|
+
"TradeStatus",
|
|
78
|
+
"TraderSide",
|
|
79
|
+
"UpDownMarket",
|
|
80
|
+
"UpDownMarketToken",
|
|
81
|
+
"UserEventType",
|
|
82
|
+
"UserOrder",
|
|
83
|
+
"UserTrade",
|
|
84
|
+
"crypto_fee",
|
|
85
|
+
]
|
polytrader/client.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from eth_account import Account
|
|
9
|
+
from py_clob_client.client import ApiCreds, ClobClient, OrderBookSummary
|
|
10
|
+
from py_clob_client.clob_types import (
|
|
11
|
+
AssetType,
|
|
12
|
+
BalanceAllowanceParams,
|
|
13
|
+
MarketOrderArgs,
|
|
14
|
+
OpenOrderParams,
|
|
15
|
+
OrderArgs,
|
|
16
|
+
PartialCreateOrderOptions,
|
|
17
|
+
TradeParams,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from polytrader.constants import (
|
|
21
|
+
CHAIN_ID,
|
|
22
|
+
CLOB_HOST,
|
|
23
|
+
DATA_API_HOST,
|
|
24
|
+
GAMMA_API_HOST,
|
|
25
|
+
TOKEN_DECIMALS,
|
|
26
|
+
)
|
|
27
|
+
from polytrader.models import (
|
|
28
|
+
ZERO,
|
|
29
|
+
Balance,
|
|
30
|
+
Coin,
|
|
31
|
+
OrderResult,
|
|
32
|
+
OrderSide,
|
|
33
|
+
PolymarketAuth,
|
|
34
|
+
PolymarketOrder,
|
|
35
|
+
PolymarketOrderType,
|
|
36
|
+
PolymarketPosition,
|
|
37
|
+
PolymarketTrade,
|
|
38
|
+
Timeframe,
|
|
39
|
+
TokenIdPair,
|
|
40
|
+
UpDownMarket,
|
|
41
|
+
)
|
|
42
|
+
from polytrader.rpc import (
|
|
43
|
+
BuilderCreds as _BuilderCreds,
|
|
44
|
+
)
|
|
45
|
+
from polytrader.rpc import (
|
|
46
|
+
approve_all as _approve_all,
|
|
47
|
+
)
|
|
48
|
+
from polytrader.rpc import (
|
|
49
|
+
approve_collateral as _approve_collateral,
|
|
50
|
+
)
|
|
51
|
+
from polytrader.rpc import (
|
|
52
|
+
approve_token as _approve_token,
|
|
53
|
+
)
|
|
54
|
+
from polytrader.websocket import PolymarketMarketWebSocket, PolymarketUserWebSocket
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PolyTrader:
|
|
60
|
+
"""Polymarket client for API credential management and WebSocket connections"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
private_key: str,
|
|
65
|
+
funder: str,
|
|
66
|
+
signature_type: int,
|
|
67
|
+
builder_key: str | None = None,
|
|
68
|
+
builder_secret: str | None = None,
|
|
69
|
+
builder_passphrase: str | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._private_key = private_key
|
|
72
|
+
self.funder: str = funder
|
|
73
|
+
self._signature_type: int = signature_type
|
|
74
|
+
self._builder_creds: _BuilderCreds | None = None
|
|
75
|
+
if builder_key and builder_secret and builder_passphrase:
|
|
76
|
+
self._builder_creds = _BuilderCreds(
|
|
77
|
+
key=builder_key, secret=builder_secret, passphrase=builder_passphrase
|
|
78
|
+
)
|
|
79
|
+
self._clob_client: ClobClient | None = None
|
|
80
|
+
self._auth: PolymarketAuth | None = None
|
|
81
|
+
self._market_ws: PolymarketMarketWebSocket | None = None
|
|
82
|
+
self._user_ws: PolymarketUserWebSocket | None = None
|
|
83
|
+
self._http = httpx.AsyncClient(timeout=10.0)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def private_key(self) -> str:
|
|
87
|
+
"""Get private key without 0x prefix"""
|
|
88
|
+
pk = self._private_key
|
|
89
|
+
return pk[2:] if pk.startswith("0x") else pk
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def wallet_address(self) -> str:
|
|
93
|
+
"""Get wallet address (MetaMask) from private key"""
|
|
94
|
+
address = Account.from_key(self._private_key).address
|
|
95
|
+
if not isinstance(address, str):
|
|
96
|
+
raise ValueError("Invalid private key")
|
|
97
|
+
return address
|
|
98
|
+
|
|
99
|
+
def _get_clob_client(self) -> ClobClient:
|
|
100
|
+
"""Get or create CLOB client"""
|
|
101
|
+
if self._clob_client is None:
|
|
102
|
+
self._clob_client = ClobClient(
|
|
103
|
+
CLOB_HOST,
|
|
104
|
+
key=self.private_key,
|
|
105
|
+
chain_id=CHAIN_ID,
|
|
106
|
+
signature_type=self._signature_type,
|
|
107
|
+
funder=self.funder,
|
|
108
|
+
)
|
|
109
|
+
return self._clob_client
|
|
110
|
+
|
|
111
|
+
def _get_authenticated_client(self) -> ClobClient:
|
|
112
|
+
"""Get CLOB client with API credentials set (cached)"""
|
|
113
|
+
client = self._get_clob_client()
|
|
114
|
+
if client.creds is None:
|
|
115
|
+
auth = self.get_auth()
|
|
116
|
+
client.set_api_creds(
|
|
117
|
+
ApiCreds(
|
|
118
|
+
api_key=auth.api_key,
|
|
119
|
+
api_secret=auth.secret,
|
|
120
|
+
api_passphrase=auth.passphrase,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return client
|
|
124
|
+
|
|
125
|
+
def _derive_credentials(self) -> None:
|
|
126
|
+
"""Derive API credentials and funder from private key"""
|
|
127
|
+
client = self._get_clob_client()
|
|
128
|
+
resp: ApiCreds = client.derive_api_key()
|
|
129
|
+
|
|
130
|
+
self._auth = PolymarketAuth(
|
|
131
|
+
api_key=resp.api_key,
|
|
132
|
+
secret=resp.api_secret,
|
|
133
|
+
passphrase=resp.api_passphrase,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
logger.info(f"[POLYMARKET] Credentials derived, funder: {self.funder}")
|
|
137
|
+
|
|
138
|
+
def get_auth(self) -> PolymarketAuth:
|
|
139
|
+
"""Get API credentials (cached)"""
|
|
140
|
+
if self._auth is None:
|
|
141
|
+
self._derive_credentials()
|
|
142
|
+
if self._auth is None:
|
|
143
|
+
raise RuntimeError("Failed to derive credentials")
|
|
144
|
+
return self._auth
|
|
145
|
+
|
|
146
|
+
# ========================================================================
|
|
147
|
+
# Market Data
|
|
148
|
+
# ========================================================================
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _parse_token_ids(market_data: dict[str, Any]) -> TokenIdPair:
|
|
152
|
+
"""Extract up/down token IDs from market data"""
|
|
153
|
+
token_ids_raw = market_data.get("clobTokenIds", "[]")
|
|
154
|
+
outcomes_raw = market_data.get("outcomes", "[]")
|
|
155
|
+
|
|
156
|
+
token_ids = (
|
|
157
|
+
json.loads(token_ids_raw)
|
|
158
|
+
if isinstance(token_ids_raw, str)
|
|
159
|
+
else token_ids_raw
|
|
160
|
+
)
|
|
161
|
+
outcomes = (
|
|
162
|
+
json.loads(outcomes_raw) if isinstance(outcomes_raw, str) else outcomes_raw
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
outcome_map: dict[str, str] = {}
|
|
166
|
+
for i, outcome in enumerate(outcomes):
|
|
167
|
+
if i < len(token_ids):
|
|
168
|
+
outcome_map[outcome.lower()] = token_ids[i]
|
|
169
|
+
|
|
170
|
+
return TokenIdPair(
|
|
171
|
+
up=outcome_map.get("up", ""), down=outcome_map.get("down", "")
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
async def get_updown_market(
|
|
175
|
+
self, coin: Coin, timeframe: Timeframe, timestamp: int
|
|
176
|
+
) -> UpDownMarket:
|
|
177
|
+
"""
|
|
178
|
+
Get Up/Down market by coin, timeframe, and Unix timestamp.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
coin: Coin enum (BTC, ETH, SOL, XRP)
|
|
182
|
+
timeframe: Timeframe enum (M5, M15)
|
|
183
|
+
timestamp: Unix timestamp for the market period
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
UpDownMarket with token IDs for Up and Down outcomes
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
ValueError: If no market found for the given parameters
|
|
190
|
+
httpx.HTTPError: If the API request fails
|
|
191
|
+
"""
|
|
192
|
+
slug = f"{coin}-updown-{timeframe}-{timestamp}"
|
|
193
|
+
url = f"{GAMMA_API_HOST}/markets?slug={slug}"
|
|
194
|
+
resp = await self._http.get(url)
|
|
195
|
+
resp.raise_for_status()
|
|
196
|
+
data = resp.json()
|
|
197
|
+
|
|
198
|
+
if not data:
|
|
199
|
+
raise ValueError(f"No market found for slug: {slug}")
|
|
200
|
+
|
|
201
|
+
market_data = data[0] if isinstance(data, list) else data
|
|
202
|
+
tokens = self._parse_token_ids(market_data)
|
|
203
|
+
|
|
204
|
+
return UpDownMarket(
|
|
205
|
+
coin=coin,
|
|
206
|
+
timeframe=timeframe,
|
|
207
|
+
condition_id=market_data.get("conditionId", ""),
|
|
208
|
+
question_id=market_data.get("questionID", ""),
|
|
209
|
+
slug=market_data.get("slug", slug),
|
|
210
|
+
title=market_data.get("question", ""),
|
|
211
|
+
up_token_id=tokens.up,
|
|
212
|
+
down_token_id=tokens.down,
|
|
213
|
+
end_date=datetime.fromisoformat(market_data["endDate"]),
|
|
214
|
+
active=market_data.get("active", False),
|
|
215
|
+
closed=market_data.get("closed", False),
|
|
216
|
+
order_price_min_tick_size=Decimal(
|
|
217
|
+
str(market_data.get("orderPriceMinTickSize", 0))
|
|
218
|
+
),
|
|
219
|
+
order_min_size=Decimal(str(market_data.get("orderMinSize", 0))),
|
|
220
|
+
neg_risk=market_data.get("negRisk", False),
|
|
221
|
+
accepting_orders=market_data.get("acceptingOrders", False),
|
|
222
|
+
best_bid=Decimal(str(market_data.get("bestBid", 0))),
|
|
223
|
+
best_ask=Decimal(str(market_data.get("bestAsk", 0))),
|
|
224
|
+
last_trade_price=Decimal(str(market_data.get("lastTradePrice", 0))),
|
|
225
|
+
spread=Decimal(str(market_data.get("spread", 0))),
|
|
226
|
+
maker_base_fee=int(market_data.get("makerBaseFee", 0)),
|
|
227
|
+
taker_base_fee=int(market_data.get("takerBaseFee", 0)),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
async def get_current_updown_market(
|
|
231
|
+
self, coin: Coin, timeframe: Timeframe
|
|
232
|
+
) -> UpDownMarket:
|
|
233
|
+
"""Get current Up/Down market for a coin and timeframe."""
|
|
234
|
+
now = datetime.now(UTC)
|
|
235
|
+
interval = 300 if timeframe == Timeframe.M5 else 900
|
|
236
|
+
rounded_ts = (int(now.timestamp()) // interval) * interval
|
|
237
|
+
return await self.get_updown_market(coin, timeframe, rounded_ts)
|
|
238
|
+
|
|
239
|
+
# ========================================================================
|
|
240
|
+
# Order Management
|
|
241
|
+
# ========================================================================
|
|
242
|
+
|
|
243
|
+
def create_order(
|
|
244
|
+
self,
|
|
245
|
+
token_id: str,
|
|
246
|
+
side: OrderSide,
|
|
247
|
+
price: Decimal,
|
|
248
|
+
size: Decimal,
|
|
249
|
+
tick_size: str = "0.01",
|
|
250
|
+
neg_risk: bool = False,
|
|
251
|
+
order_type: PolymarketOrderType = PolymarketOrderType.GTC,
|
|
252
|
+
expiration: int = 0,
|
|
253
|
+
post_only: bool = False,
|
|
254
|
+
) -> OrderResult:
|
|
255
|
+
"""
|
|
256
|
+
Create and post an order.
|
|
257
|
+
|
|
258
|
+
For limit orders (GTC, GTD): specify price and size.
|
|
259
|
+
For market orders (FOK, FAK): size is the dollar amount for BUY,
|
|
260
|
+
or number of shares for SELL. Price acts as worst-price limit.
|
|
261
|
+
For MARKET pseudo-type: converted to FOK with aggressive price.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
token_id: The token ID (asset ID) to trade
|
|
265
|
+
side: BUY or SELL
|
|
266
|
+
price: Limit price (slippage protection for FOK/FAK)
|
|
267
|
+
size: Shares for limit/SELL, dollar amount for FOK/FAK BUY
|
|
268
|
+
tick_size: Market tick size ("0.1", "0.01", "0.001", "0.0001")
|
|
269
|
+
neg_risk: Whether this is a negative risk market (3+ outcomes)
|
|
270
|
+
order_type: GTC, GTD, FOK, FAK, or MARKET
|
|
271
|
+
expiration: Unix timestamp for GTD orders (add 60s security buffer)
|
|
272
|
+
post_only: Reject if order would match immediately (GTC/GTD only)
|
|
273
|
+
"""
|
|
274
|
+
client = self._get_authenticated_client()
|
|
275
|
+
options = PartialCreateOrderOptions(tick_size=tick_size, neg_risk=neg_risk)
|
|
276
|
+
|
|
277
|
+
actual_order_type = order_type
|
|
278
|
+
if order_type == PolymarketOrderType.MARKET:
|
|
279
|
+
actual_order_type = PolymarketOrderType.FOK
|
|
280
|
+
|
|
281
|
+
if actual_order_type in (PolymarketOrderType.FOK, PolymarketOrderType.FAK):
|
|
282
|
+
# Market orders: use create_market_order
|
|
283
|
+
market_price = price
|
|
284
|
+
if order_type == PolymarketOrderType.MARKET:
|
|
285
|
+
market_price = (
|
|
286
|
+
Decimal("0.99") if side == OrderSide.BUY else Decimal("0.01")
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
market_args = MarketOrderArgs(
|
|
290
|
+
token_id=token_id,
|
|
291
|
+
amount=float(size),
|
|
292
|
+
side=side.value,
|
|
293
|
+
price=float(market_price),
|
|
294
|
+
)
|
|
295
|
+
signed_order = client.create_market_order(market_args, options)
|
|
296
|
+
else:
|
|
297
|
+
# Limit orders: GTC or GTD
|
|
298
|
+
order_args = OrderArgs(
|
|
299
|
+
token_id=token_id,
|
|
300
|
+
price=float(price),
|
|
301
|
+
size=float(size),
|
|
302
|
+
side=side.value,
|
|
303
|
+
expiration=expiration,
|
|
304
|
+
)
|
|
305
|
+
signed_order = client.create_order(order_args, options)
|
|
306
|
+
|
|
307
|
+
resp = client.post_order(
|
|
308
|
+
signed_order,
|
|
309
|
+
orderType=actual_order_type.to_clob_order_type(),
|
|
310
|
+
post_only=post_only
|
|
311
|
+
and actual_order_type
|
|
312
|
+
in (
|
|
313
|
+
PolymarketOrderType.GTC,
|
|
314
|
+
PolymarketOrderType.GTD,
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
result = OrderResult.from_dict(resp)
|
|
319
|
+
|
|
320
|
+
logger.info(
|
|
321
|
+
"[POLYMARKET] Order %s: %s %s@%s type=%s id=%s",
|
|
322
|
+
result.status.value,
|
|
323
|
+
side.value,
|
|
324
|
+
size,
|
|
325
|
+
price,
|
|
326
|
+
order_type.value,
|
|
327
|
+
result.order_id,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
@staticmethod
|
|
333
|
+
def _extract_cancelled(resp: dict[str, Any]) -> list[str]:
|
|
334
|
+
"""Extract cancelled order IDs from API response (handles both spellings)"""
|
|
335
|
+
result = resp.get("canceled", resp.get("cancelled", []))
|
|
336
|
+
return result if isinstance(result, list) else []
|
|
337
|
+
|
|
338
|
+
def cancel_order(self, order_id: str) -> bool:
|
|
339
|
+
"""Cancel a specific order."""
|
|
340
|
+
client = self._get_authenticated_client()
|
|
341
|
+
resp = client.cancel(order_id)
|
|
342
|
+
cancelled = bool(
|
|
343
|
+
self._extract_cancelled(resp)
|
|
344
|
+
or resp.get("canceled", resp.get("cancelled", False))
|
|
345
|
+
)
|
|
346
|
+
if cancelled:
|
|
347
|
+
logger.info(f"[POLYMARKET] Order cancelled: {order_id}")
|
|
348
|
+
return cancelled
|
|
349
|
+
|
|
350
|
+
def cancel_all_orders(self) -> int:
|
|
351
|
+
"""Cancel all open orders."""
|
|
352
|
+
client = self._get_authenticated_client()
|
|
353
|
+
resp = client.cancel_all()
|
|
354
|
+
cancelled_ids = self._extract_cancelled(resp)
|
|
355
|
+
logger.info(f"[POLYMARKET] Cancelled {len(cancelled_ids)} orders")
|
|
356
|
+
return len(cancelled_ids)
|
|
357
|
+
|
|
358
|
+
def cancel_orders_for_market(self, market_id: str) -> int:
|
|
359
|
+
"""Cancel all orders for a specific market."""
|
|
360
|
+
client = self._get_authenticated_client()
|
|
361
|
+
resp = client.cancel_market_orders(market_id)
|
|
362
|
+
cancelled_ids = self._extract_cancelled(resp)
|
|
363
|
+
logger.info(
|
|
364
|
+
f"[POLYMARKET] Cancelled {len(cancelled_ids)} orders for market {market_id}"
|
|
365
|
+
)
|
|
366
|
+
return len(cancelled_ids)
|
|
367
|
+
|
|
368
|
+
# ========================================================================
|
|
369
|
+
# Order/Position Queries
|
|
370
|
+
# ========================================================================
|
|
371
|
+
|
|
372
|
+
def get_order(self, order_id: str) -> PolymarketOrder:
|
|
373
|
+
"""Get a single order by ID."""
|
|
374
|
+
client = self._get_authenticated_client()
|
|
375
|
+
resp = client.get_order(order_id)
|
|
376
|
+
return PolymarketOrder(**resp)
|
|
377
|
+
|
|
378
|
+
def get_orders(
|
|
379
|
+
self,
|
|
380
|
+
market_id: str | None = None,
|
|
381
|
+
asset_id: str | None = None,
|
|
382
|
+
) -> list[PolymarketOrder]:
|
|
383
|
+
"""Get open orders, optionally filtered by market or asset."""
|
|
384
|
+
client = self._get_authenticated_client()
|
|
385
|
+
params = OpenOrderParams(market=market_id, asset_id=asset_id)
|
|
386
|
+
resp = client.get_orders(params)
|
|
387
|
+
return [PolymarketOrder(**d) for d in resp]
|
|
388
|
+
|
|
389
|
+
def get_trades(
|
|
390
|
+
self,
|
|
391
|
+
market_id: str | None = None,
|
|
392
|
+
asset_id: str | None = None,
|
|
393
|
+
) -> list[PolymarketTrade]:
|
|
394
|
+
"""Get trade history."""
|
|
395
|
+
client = self._get_authenticated_client()
|
|
396
|
+
params = TradeParams(market=market_id, asset_id=asset_id)
|
|
397
|
+
resp = client.get_trades(params)
|
|
398
|
+
return [PolymarketTrade(**d) for d in resp]
|
|
399
|
+
|
|
400
|
+
async def get_positions(self) -> list[PolymarketPosition]:
|
|
401
|
+
"""Get current positions from the data API."""
|
|
402
|
+
url = f"{DATA_API_HOST}/positions?user={self.funder}"
|
|
403
|
+
resp = await self._http.get(url)
|
|
404
|
+
resp.raise_for_status()
|
|
405
|
+
data = resp.json()
|
|
406
|
+
return [PolymarketPosition.from_dict(pos_data) for pos_data in data]
|
|
407
|
+
|
|
408
|
+
def get_balance(self) -> Balance:
|
|
409
|
+
"""Get USDC balance and allowance."""
|
|
410
|
+
client = self._get_authenticated_client()
|
|
411
|
+
params = BalanceAllowanceParams(
|
|
412
|
+
asset_type=cast(AssetType, AssetType.COLLATERAL)
|
|
413
|
+
)
|
|
414
|
+
resp = cast(dict[str, Any], client.get_balance_allowance(params))
|
|
415
|
+
return Balance.from_dict(resp)
|
|
416
|
+
|
|
417
|
+
def get_token_balance(self, token_id: str) -> Balance:
|
|
418
|
+
"""Get conditional token balance and allowance."""
|
|
419
|
+
client = self._get_authenticated_client()
|
|
420
|
+
params = BalanceAllowanceParams(
|
|
421
|
+
asset_type=cast(AssetType, AssetType.CONDITIONAL),
|
|
422
|
+
token_id=token_id,
|
|
423
|
+
)
|
|
424
|
+
resp = cast(dict[str, Any], client.get_balance_allowance(params))
|
|
425
|
+
return Balance.from_dict(resp)
|
|
426
|
+
|
|
427
|
+
def get_orderbook(self, token_id: str) -> OrderBookSummary:
|
|
428
|
+
"""Get orderbook for a token."""
|
|
429
|
+
client = self._get_clob_client()
|
|
430
|
+
return client.get_order_book(token_id)
|
|
431
|
+
|
|
432
|
+
# ========================================================================
|
|
433
|
+
# Balance & Allowance
|
|
434
|
+
# ========================================================================
|
|
435
|
+
|
|
436
|
+
def ensure_can_sell(
|
|
437
|
+
self, token_id: str, size: Decimal, neg_risk: bool = False
|
|
438
|
+
) -> bool:
|
|
439
|
+
"""
|
|
440
|
+
Check if a sell order is possible, auto-approving if needed.
|
|
441
|
+
|
|
442
|
+
Verifies token balance, on-chain allowance, and available (unlocked)
|
|
443
|
+
shares. If allowance is zero, sends an on-chain ``setApprovalForAll``
|
|
444
|
+
transaction and refreshes the server cache.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
token_id: Conditional token to sell.
|
|
448
|
+
size: Number of shares to sell (human-readable, e.g. ``5.0``).
|
|
449
|
+
neg_risk: Whether this is a neg-risk market.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
True if sell of given size is possible.
|
|
453
|
+
"""
|
|
454
|
+
token_bal = self.get_token_balance(token_id)
|
|
455
|
+
|
|
456
|
+
# Balance & allowance from the CLOB API are in raw 6-decimal units;
|
|
457
|
+
# *size* is human-readable (create_order multiplies by 1e6 internally).
|
|
458
|
+
raw_size = size * TOKEN_DECIMALS
|
|
459
|
+
|
|
460
|
+
if token_bal.balance < raw_size:
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
if token_bal.allowance < raw_size:
|
|
464
|
+
self.approve_token(neg_risk)
|
|
465
|
+
self.refresh_token_allowance(token_id)
|
|
466
|
+
token_bal = self.get_token_balance(token_id)
|
|
467
|
+
if token_bal.allowance < raw_size:
|
|
468
|
+
return False
|
|
469
|
+
|
|
470
|
+
# Account for tokens locked in open sell orders (human-readable sizes)
|
|
471
|
+
open_orders = self.get_orders(asset_id=token_id)
|
|
472
|
+
locked_raw = sum(
|
|
473
|
+
(
|
|
474
|
+
o.size_remaining * TOKEN_DECIMALS
|
|
475
|
+
for o in open_orders
|
|
476
|
+
if o.side == OrderSide.SELL
|
|
477
|
+
),
|
|
478
|
+
ZERO,
|
|
479
|
+
)
|
|
480
|
+
available = token_bal.balance - locked_raw
|
|
481
|
+
return available >= raw_size
|
|
482
|
+
|
|
483
|
+
def refresh_token_allowance(self, token_id: str) -> None:
|
|
484
|
+
"""Refresh server's cached balance/allowance for a conditional token.
|
|
485
|
+
|
|
486
|
+
This does NOT perform on-chain approval. It tells the CLOB server to
|
|
487
|
+
re-read on-chain state. For first-time token approval, use the
|
|
488
|
+
Polymarket UI or send a setApprovalForAll transaction on-chain.
|
|
489
|
+
"""
|
|
490
|
+
client = self._get_authenticated_client()
|
|
491
|
+
params = BalanceAllowanceParams(
|
|
492
|
+
asset_type=cast(AssetType, AssetType.CONDITIONAL),
|
|
493
|
+
token_id=token_id,
|
|
494
|
+
)
|
|
495
|
+
client.update_balance_allowance(params)
|
|
496
|
+
|
|
497
|
+
def refresh_collateral_allowance(self) -> None:
|
|
498
|
+
"""Refresh server's cached balance/allowance for USDC collateral."""
|
|
499
|
+
client = self._get_authenticated_client()
|
|
500
|
+
params = BalanceAllowanceParams(
|
|
501
|
+
asset_type=cast(AssetType, AssetType.COLLATERAL),
|
|
502
|
+
)
|
|
503
|
+
client.update_balance_allowance(params)
|
|
504
|
+
|
|
505
|
+
def approve_token(self, neg_risk: bool = False) -> str:
|
|
506
|
+
"""Approve the CTF conditional tokens for the exchange on-chain.
|
|
507
|
+
|
|
508
|
+
Sends a setApprovalForAll transaction on the CTF ERC1155 contract.
|
|
509
|
+
One-time setup per exchange (neg_risk vs non-neg_risk).
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
Transaction hash.
|
|
513
|
+
"""
|
|
514
|
+
return _approve_token(
|
|
515
|
+
self._private_key, neg_risk, self.funder, self._builder_creds
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
def approve_collateral(self, neg_risk: bool = False) -> str:
|
|
519
|
+
"""Approve USDC for the exchange contract on-chain.
|
|
520
|
+
|
|
521
|
+
Sends an ERC20 approve transaction for max uint256.
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
Transaction hash.
|
|
525
|
+
"""
|
|
526
|
+
return _approve_collateral(
|
|
527
|
+
self._private_key, neg_risk, self.funder, self._builder_creds
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def approve_all(self) -> list[str]:
|
|
531
|
+
"""Approve both exchanges (neg_risk + non-neg_risk) for tokens and USDC.
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
List of transaction hashes.
|
|
535
|
+
"""
|
|
536
|
+
return _approve_all(self._private_key, self.funder, self._builder_creds)
|
|
537
|
+
|
|
538
|
+
# ========================================================================
|
|
539
|
+
# WebSocket
|
|
540
|
+
# ========================================================================
|
|
541
|
+
|
|
542
|
+
@property
|
|
543
|
+
def market_ws(self) -> PolymarketMarketWebSocket:
|
|
544
|
+
"""Lazy-initialized market WebSocket (public, subscribes by asset_id)."""
|
|
545
|
+
if self._market_ws is None:
|
|
546
|
+
self._market_ws = PolymarketMarketWebSocket()
|
|
547
|
+
return self._market_ws
|
|
548
|
+
|
|
549
|
+
@property
|
|
550
|
+
def user_ws(self) -> PolymarketUserWebSocket:
|
|
551
|
+
"""Lazy-initialized user WebSocket (authenticated, subscribes by market_id)."""
|
|
552
|
+
if self._user_ws is None:
|
|
553
|
+
self._user_ws = PolymarketUserWebSocket(auth=self.get_auth())
|
|
554
|
+
return self._user_ws
|
polytrader/constants.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
|
|
3
|
+
CLOB_HOST = "https://clob.polymarket.com"
|
|
4
|
+
GAMMA_API_HOST = "https://gamma-api.polymarket.com"
|
|
5
|
+
DATA_API_HOST = "https://data-api.polymarket.com"
|
|
6
|
+
CHAIN_ID = 137 # Polygon mainnet
|
|
7
|
+
POLYGON_RPC = "https://polygon-bor-rpc.publicnode.com"
|
|
8
|
+
RELAYER_HOST = "https://relayer-v2.polymarket.com"
|
|
9
|
+
|
|
10
|
+
# Conditional tokens and USDC on Polygon both use 6 decimals.
|
|
11
|
+
# The CLOB balance API returns raw values; create_order expects human-readable.
|
|
12
|
+
TOKEN_DECIMALS = Decimal("1000000")
|
|
13
|
+
|
|
14
|
+
# Crypto-market fee formula: fee = size * price * CRYPTO_FEE_RATE * (price * (1 - price))^CRYPTO_FEE_EXPONENT
|
|
15
|
+
CRYPTO_FEE_RATE = Decimal("0.25")
|
|
16
|
+
CRYPTO_FEE_EXPONENT = 2
|