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,825 @@
|
|
|
1
|
+
"""Polymarket US order entry, on the exchange API for onboarded firms.
|
|
2
|
+
|
|
3
|
+
`api.{preprod,prod}.polymarketexchange.com`, reached with an Auth0
|
|
4
|
+
private-key JWT exchanged for a three-minute access token and a participant
|
|
5
|
+
id issued at onboarding. The retail API most accounts use is
|
|
6
|
+
`polymarket_us`; this is the one a firm gets after onboarding, with a
|
|
7
|
+
preprod environment, firm-wide rate limits and native stop orders.
|
|
8
|
+
|
|
9
|
+
What differs from the retail API, and shapes this adapter:
|
|
10
|
+
|
|
11
|
+
**Integers on the wire.** Prices and quantities are int64s scaled per
|
|
12
|
+
instrument (`priceScale`, `fractionalQtyScale` from reference data). The
|
|
13
|
+
scales are read once per symbol and cached: reference data is limited to six
|
|
14
|
+
requests a minute firm-wide.
|
|
15
|
+
|
|
16
|
+
**The YES leg only.** Orders carry a side and a YES price, nothing more.
|
|
17
|
+
Buying NO at `q` is sent as selling YES at `1 - q` and reads back that way,
|
|
18
|
+
as on Kalshi.
|
|
19
|
+
|
|
20
|
+
**Stops are held by the exchange.** `ORDER_TYPE_STOP` and
|
|
21
|
+
`ORDER_TYPE_STOP_LIMIT` are in the published order schema, so a stop-market
|
|
22
|
+
or stop-limit request is sent as one; a NO stop is the YES stop at `1 - s`
|
|
23
|
+
on the other side, which triggers on the same event. Trailing stops and the
|
|
24
|
+
rest remain the engine's.
|
|
25
|
+
|
|
26
|
+
**Reads are rationed.** Open orders are cheap; searching orders or
|
|
27
|
+
executions is twelve requests a minute firm-wide, and each has its own
|
|
28
|
+
budget here so a reconciliation loop cannot starve order entry.
|
|
29
|
+
|
|
30
|
+
**Entry is asynchronous.** An insert returns an order id; acceptance,
|
|
31
|
+
fills and rejection follow. Cancels and replaces return nothing. Results are
|
|
32
|
+
`pending`, `pending_cancel` or `pending_replace` unless read back.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import time
|
|
38
|
+
import uuid
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from datetime import datetime, timezone
|
|
41
|
+
from decimal import Decimal
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
from ..base import AsyncHttpClient, Capability
|
|
45
|
+
from .. import ids
|
|
46
|
+
from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, NotSupported
|
|
47
|
+
from ..polymarket_us import fee_schedule_of, parse_ts
|
|
48
|
+
from ..types import Page
|
|
49
|
+
from .base import TradingExchange
|
|
50
|
+
from .credentials import PolymarketUSExchangeCredentials
|
|
51
|
+
from .errors import (
|
|
52
|
+
CredentialsMissing, DuplicateClientOrderId, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound,
|
|
53
|
+
OrderRejected, PermissionDenied,
|
|
54
|
+
)
|
|
55
|
+
from .limiter import BudgetLimiter, Priority
|
|
56
|
+
from .money import D, validate_amount, validate_price
|
|
57
|
+
from .types import (
|
|
58
|
+
Account, Balance, EditRequest, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType,
|
|
59
|
+
Position, PositionSide, Precision, Side, TimeInForce,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
VENUE = "polymarket_us"
|
|
63
|
+
|
|
64
|
+
ENVIRONMENTS = {
|
|
65
|
+
"preprod": ("https://api.preprod.polymarketexchange.com", "pmx-preprod.us.auth0.com"),
|
|
66
|
+
"prod": ("https://api.prod.polymarketexchange.com", "pmx-prod.us.auth0.com"),
|
|
67
|
+
}
|
|
68
|
+
GATEWAY_URL = "https://gateway.polymarket.us"
|
|
69
|
+
|
|
70
|
+
BATCH = 20
|
|
71
|
+
TOKEN_MARGIN_S = 30
|
|
72
|
+
"""Refresh the access token this long before the venue says it expires."""
|
|
73
|
+
|
|
74
|
+
NATIVE_ORDER_TYPES = frozenset({OrderType.LIMIT, OrderType.MARKET, OrderType.STOP_MARKET, OrderType.STOP_LIMIT})
|
|
75
|
+
|
|
76
|
+
TIF_TO_WIRE = {
|
|
77
|
+
TimeInForce.GTC: "TIME_IN_FORCE_GOOD_TILL_CANCEL",
|
|
78
|
+
TimeInForce.GTD: "TIME_IN_FORCE_GOOD_TILL_TIME",
|
|
79
|
+
TimeInForce.IOC: "TIME_IN_FORCE_IMMEDIATE_OR_CANCEL",
|
|
80
|
+
TimeInForce.FOK: "TIME_IN_FORCE_FILL_OR_KILL",
|
|
81
|
+
}
|
|
82
|
+
TIF_FROM_WIRE = {wire: tif for tif, wire in TIF_TO_WIRE.items()} | {"TIME_IN_FORCE_DAY": TimeInForce.DAY}
|
|
83
|
+
|
|
84
|
+
TYPE_TO_WIRE = {
|
|
85
|
+
OrderType.LIMIT: "ORDER_TYPE_LIMIT",
|
|
86
|
+
OrderType.MARKET: "ORDER_TYPE_LIMIT",
|
|
87
|
+
OrderType.STOP_MARKET: "ORDER_TYPE_STOP",
|
|
88
|
+
OrderType.STOP_LIMIT: "ORDER_TYPE_STOP_LIMIT",
|
|
89
|
+
}
|
|
90
|
+
TYPE_FROM_WIRE = {
|
|
91
|
+
"ORDER_TYPE_LIMIT": OrderType.LIMIT,
|
|
92
|
+
"ORDER_TYPE_MARKET_TO_LIMIT": OrderType.MARKET,
|
|
93
|
+
"ORDER_TYPE_STOP": OrderType.STOP_MARKET,
|
|
94
|
+
"ORDER_TYPE_STOP_LIMIT": OrderType.STOP_LIMIT,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
STATE = {
|
|
98
|
+
"ORDER_STATE_PENDING_NEW": OrderStatus.PENDING,
|
|
99
|
+
"ORDER_STATE_PENDING_RISK": OrderStatus.PENDING,
|
|
100
|
+
"ORDER_STATE_NEW": OrderStatus.OPEN,
|
|
101
|
+
"ORDER_STATE_PARTIALLY_FILLED": OrderStatus.OPEN,
|
|
102
|
+
"ORDER_STATE_PENDING_REPLACE": OrderStatus.PENDING_REPLACE,
|
|
103
|
+
"ORDER_STATE_PENDING_CANCEL": OrderStatus.PENDING_CANCEL,
|
|
104
|
+
"ORDER_STATE_FILLED": OrderStatus.CLOSED,
|
|
105
|
+
"ORDER_STATE_CANCELED": OrderStatus.CANCELED,
|
|
106
|
+
"ORDER_STATE_REPLACED": OrderStatus.CANCELED,
|
|
107
|
+
"ORDER_STATE_REJECTED": OrderStatus.REJECTED,
|
|
108
|
+
"ORDER_STATE_EXPIRED": OrderStatus.EXPIRED,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Instruments and scaling
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class InstrumentScale:
|
|
118
|
+
"""How one symbol's integers map to prices and contracts."""
|
|
119
|
+
|
|
120
|
+
symbol: str
|
|
121
|
+
price_scale: int
|
|
122
|
+
qty_scale: int
|
|
123
|
+
tick: Decimal
|
|
124
|
+
min_quantity: Decimal
|
|
125
|
+
state: str = ""
|
|
126
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def precision(self) -> Precision:
|
|
130
|
+
whole = self.min_quantity >= 1 and self.min_quantity % 1 == 0
|
|
131
|
+
return Precision(tick=self.tick, min_amount=self.min_quantity, amount_step=self.min_quantity, whole_contracts=whole)
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_instrument(cls, raw: dict[str, Any]) -> "InstrumentScale":
|
|
135
|
+
price_scale = int(raw.get("priceScale") or 1)
|
|
136
|
+
qty_scale = int(raw.get("fractionalQtyScale") or 1) or 1
|
|
137
|
+
tick = D(str(raw.get("tickSize"))) if raw.get("tickSize") else Decimal(1) / price_scale
|
|
138
|
+
if tick >= 1:
|
|
139
|
+
# A dollar tick of 1 or more cannot exist on a $1 contract, so a
|
|
140
|
+
# tick that large is in scaled units.
|
|
141
|
+
tick = tick / price_scale
|
|
142
|
+
minimum = D(str(raw.get("minimumTradeQty") or qty_scale)) / qty_scale
|
|
143
|
+
return cls(
|
|
144
|
+
symbol=str(raw.get("symbol") or ""), price_scale=price_scale, qty_scale=qty_scale,
|
|
145
|
+
tick=tick, min_quantity=minimum or Decimal(1), state=str(raw.get("state") or ""), raw=raw,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def price_to_wire(self, price: Decimal) -> str:
|
|
149
|
+
scaled = price * self.price_scale
|
|
150
|
+
if scaled != scaled.to_integral_value():
|
|
151
|
+
raise InvalidOrder(f"polymarket_us: price {price} is finer than {self.symbol}'s price scale {self.price_scale}")
|
|
152
|
+
return str(int(scaled))
|
|
153
|
+
|
|
154
|
+
def qty_to_wire(self, quantity: Decimal) -> str:
|
|
155
|
+
scaled = quantity * self.qty_scale
|
|
156
|
+
if scaled != scaled.to_integral_value():
|
|
157
|
+
raise InvalidOrder(f"polymarket_us: quantity {quantity} is finer than {self.symbol}'s quantity scale {self.qty_scale}")
|
|
158
|
+
return str(int(scaled))
|
|
159
|
+
|
|
160
|
+
def price_from_wire(self, value: Any) -> Decimal | None:
|
|
161
|
+
if value in (None, "", "0", 0):
|
|
162
|
+
return None
|
|
163
|
+
return D(str(value)) / self.price_scale
|
|
164
|
+
|
|
165
|
+
def qty_from_wire(self, value: Any) -> Decimal:
|
|
166
|
+
return D(str(value or 0)) / self.qty_scale
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Pure translation
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def symbol_of(market_id: str) -> str:
|
|
174
|
+
"""The exchange symbol (the market slug) behind a Synpath market id or a
|
|
175
|
+
bare slug. Another venue's id is refused before anything is signed."""
|
|
176
|
+
try:
|
|
177
|
+
return ids.native(VENUE, market_id)
|
|
178
|
+
except BadRequest as exc:
|
|
179
|
+
raise InvalidOrder(str(exc)) from None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def rfc3339(ms: int) -> str:
|
|
183
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def wire_side(side: Side) -> str:
|
|
187
|
+
"""Orders are already on the YES leg, which is what the exchange trades."""
|
|
188
|
+
return "SIDE_BUY" if side == Side.BUY else "SIDE_SELL"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def translate_order(request: OrderRequest, scale: InstrumentScale, *, account: str) -> dict[str, Any]:
|
|
192
|
+
"""An `OrderRequest` as an `InsertOrderRequest`, on the YES leg."""
|
|
193
|
+
if request.type not in NATIVE_ORDER_TYPES:
|
|
194
|
+
raise InvalidOrder(
|
|
195
|
+
f"polymarket_us: {request.type.value} is held by the execution engine; this venue holds "
|
|
196
|
+
f"limit, stop-market and stop-limit orders"
|
|
197
|
+
)
|
|
198
|
+
if request.time_in_force == TimeInForce.DAY:
|
|
199
|
+
raise InvalidOrder("polymarket_us: 'day' is rewritten to 'gtd' by the engine; the venue's DAY does not cancel at the roll")
|
|
200
|
+
if request.reduce_only:
|
|
201
|
+
raise InvalidOrder("polymarket_us: the exchange API has no reduce-only flag")
|
|
202
|
+
symbol = symbol_of(request.market_id)
|
|
203
|
+
precision = scale.precision
|
|
204
|
+
needs_price = request.type in (OrderType.LIMIT, OrderType.MARKET, OrderType.STOP_LIMIT)
|
|
205
|
+
if needs_price and request.price is None:
|
|
206
|
+
raise InvalidOrder(
|
|
207
|
+
"polymarket_us: a price is required -- a market order is sent as an immediate limit at the "
|
|
208
|
+
"protection price you give, and a stop-limit needs its limit"
|
|
209
|
+
)
|
|
210
|
+
if request.type in (OrderType.STOP_MARKET, OrderType.STOP_LIMIT) and request.stop_price is None:
|
|
211
|
+
raise InvalidOrder("polymarket_us: a stop order needs stop_price")
|
|
212
|
+
price = validate_price(D(request.price), precision) if needs_price else None
|
|
213
|
+
stop = validate_price(D(request.stop_price), precision) if request.stop_price is not None else None
|
|
214
|
+
quantity = validate_amount(D(request.amount), precision)
|
|
215
|
+
side, yes_price, yes_stop = wire_side(request.side), price, stop
|
|
216
|
+
tif = request.time_in_force
|
|
217
|
+
if request.type == OrderType.MARKET:
|
|
218
|
+
tif = TimeInForce.FOK if tif == TimeInForce.FOK else TimeInForce.IOC
|
|
219
|
+
if request.post_only and tif not in (TimeInForce.GTC, TimeInForce.GTD):
|
|
220
|
+
raise InvalidOrder("polymarket_us: post-only orders must be gtc or gtd")
|
|
221
|
+
body: dict[str, Any] = {
|
|
222
|
+
"type": TYPE_TO_WIRE[request.type],
|
|
223
|
+
"side": side,
|
|
224
|
+
"orderQty": scale.qty_to_wire(quantity),
|
|
225
|
+
"symbol": symbol,
|
|
226
|
+
"timeInForce": TIF_TO_WIRE[tif],
|
|
227
|
+
"clordId": request.client_order_id or str(uuid.uuid4()),
|
|
228
|
+
"account": account,
|
|
229
|
+
"manualOrderIndicator": "MANUAL_ORDER_INDICATOR_MANUAL" if request.params.get("manual") else "MANUAL_ORDER_INDICATOR_AUTOMATED",
|
|
230
|
+
}
|
|
231
|
+
if yes_price is not None:
|
|
232
|
+
body["price"] = scale.price_to_wire(yes_price)
|
|
233
|
+
if yes_stop is not None:
|
|
234
|
+
body["stopPrice"] = scale.price_to_wire(yes_stop)
|
|
235
|
+
if tif == TimeInForce.GTD:
|
|
236
|
+
if request.expires_at is None:
|
|
237
|
+
raise InvalidOrder("polymarket_us: a gtd order needs expires_at")
|
|
238
|
+
body["goodTillTime"] = rfc3339(request.expires_at)
|
|
239
|
+
if request.post_only:
|
|
240
|
+
body["participateDontInitiate"] = True
|
|
241
|
+
for key in ("selfMatchPreventionInstruction", "selfMatchPreventionId", "orderCapacity", "minQty", "allOrNone"):
|
|
242
|
+
if key in request.params:
|
|
243
|
+
body[key] = request.params[key]
|
|
244
|
+
return body
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# Normalizers
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def order_of(raw: dict[str, Any], scale: InstrumentScale, *, account: Account | None = None) -> Order:
|
|
252
|
+
"""An exchange order, on the YES leg."""
|
|
253
|
+
symbol = str(raw.get("symbol") or scale.symbol)
|
|
254
|
+
amount = scale.qty_from_wire(raw.get("orderQty"))
|
|
255
|
+
filled = scale.qty_from_wire(raw.get("cumQty"))
|
|
256
|
+
leaves = raw.get("leavesQty")
|
|
257
|
+
return Order(
|
|
258
|
+
id=str(raw.get("id") or ""),
|
|
259
|
+
client_order_id=raw.get("clordId") or None,
|
|
260
|
+
venue=VENUE,
|
|
261
|
+
account=account,
|
|
262
|
+
market_id=ids.qualify(VENUE, symbol),
|
|
263
|
+
side=Side.BUY if raw.get("side") == "SIDE_BUY" else Side.SELL,
|
|
264
|
+
type=TYPE_FROM_WIRE.get(str(raw.get("type") or ""), OrderType.LIMIT),
|
|
265
|
+
time_in_force=TIF_FROM_WIRE.get(str(raw.get("timeInForce") or ""), TimeInForce.GTC),
|
|
266
|
+
status=STATE.get(str(raw.get("state") or ""), OrderStatus.PENDING),
|
|
267
|
+
price=scale.price_from_wire(raw.get("price")),
|
|
268
|
+
stop_price=scale.price_from_wire(raw.get("stopPrice")),
|
|
269
|
+
amount=amount,
|
|
270
|
+
filled=filled,
|
|
271
|
+
remaining=scale.qty_from_wire(leaves) if leaves is not None else None,
|
|
272
|
+
average_price=scale.price_from_wire(raw.get("avgPx")) if filled > 0 else None,
|
|
273
|
+
post_only=bool(raw.get("participateDontInitiate")),
|
|
274
|
+
expires_at=parse_ts(raw.get("goodTillTime")),
|
|
275
|
+
created_at=parse_ts(raw.get("createTime")),
|
|
276
|
+
updated_at=parse_ts(raw.get("lastTransactTime") or raw.get("insertTime")),
|
|
277
|
+
info=raw,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def scale_from_order(order: dict[str, Any], fallback: InstrumentScale | None = None) -> InstrumentScale | None:
|
|
282
|
+
"""The scales an order carries (copied from its instrument when it was
|
|
283
|
+
entered), or `fallback` where it carries none."""
|
|
284
|
+
price_scale = int(order.get("priceScale") or 0)
|
|
285
|
+
if not price_scale:
|
|
286
|
+
return fallback
|
|
287
|
+
qty_scale = int(order.get("fractionalQuantityScale") or 0) or 1
|
|
288
|
+
if fallback is not None and (fallback.price_scale, fallback.qty_scale) == (price_scale, qty_scale):
|
|
289
|
+
return fallback
|
|
290
|
+
return InstrumentScale(
|
|
291
|
+
symbol=str(order.get("symbol") or (fallback.symbol if fallback else "")), price_scale=price_scale,
|
|
292
|
+
qty_scale=qty_scale, tick=fallback.tick if fallback else Decimal(1) / price_scale,
|
|
293
|
+
min_quantity=fallback.min_quantity if fallback else Decimal(1) / qty_scale,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def commission_of(value: Any, scale: InstrumentScale) -> Decimal | None:
|
|
298
|
+
"""A commission field in dollars. Commissions are notional units: one
|
|
299
|
+
dollar is `price_scale * fractional_quantity_scale` of them. Negative is
|
|
300
|
+
a rebate."""
|
|
301
|
+
if value in (None, ""):
|
|
302
|
+
return None
|
|
303
|
+
return D(str(value)) / (scale.price_scale * scale.qty_scale)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def fill_of(raw: dict[str, Any], scale: InstrumentScale, *, account: Account | None = None) -> Fill:
|
|
307
|
+
order = raw.get("order") or {}
|
|
308
|
+
scale = scale_from_order(order, scale) or scale
|
|
309
|
+
symbol = str(order.get("symbol") or scale.symbol)
|
|
310
|
+
return Fill(
|
|
311
|
+
id=str(raw.get("tradeId") or raw.get("id") or ""),
|
|
312
|
+
order_id=str(order.get("id") or ""),
|
|
313
|
+
client_order_id=order.get("clordId") or None,
|
|
314
|
+
venue=VENUE,
|
|
315
|
+
account=account,
|
|
316
|
+
market_id=ids.qualify(VENUE, symbol),
|
|
317
|
+
side=Side.BUY if order.get("side") == "SIDE_BUY" else Side.SELL,
|
|
318
|
+
price=scale.price_from_wire(raw.get("lastPx")) or Decimal("0"),
|
|
319
|
+
amount=scale.qty_from_wire(raw.get("lastShares")),
|
|
320
|
+
fee=commission_of(raw.get("commissionNotionalCollected"), scale),
|
|
321
|
+
fee_currency="USD",
|
|
322
|
+
liquidity=Liquidity.TAKER if raw.get("aggressor") else Liquidity.MAKER,
|
|
323
|
+
timestamp=parse_ts(raw.get("transactTime")) or 0,
|
|
324
|
+
info=raw,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def position_of(raw: dict[str, Any], scale: InstrumentScale, *, account: Account | None = None) -> Position:
|
|
329
|
+
"""A netted position. Cost and realized P&L stay in `info`: the schema
|
|
330
|
+
gives them as integers without saying which scale they use."""
|
|
331
|
+
symbol = str(raw.get("symbol") or scale.symbol)
|
|
332
|
+
net = scale.qty_from_wire(raw.get("netPosition"))
|
|
333
|
+
return Position(
|
|
334
|
+
venue=VENUE, account=account, market_id=ids.qualify(VENUE, symbol),
|
|
335
|
+
side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT,
|
|
336
|
+
contracts=abs(net), resolved=bool(raw.get("expired")),
|
|
337
|
+
timestamp=parse_ts(raw.get("updateTime")), info=raw,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
GRPC_CODES = {3: "invalid_argument", 5: "not_found", 6: "already_exists", 7: "permission_denied", 8: "resource_exhausted", 9: "failed_precondition"}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def error_of(exc: ExchangeError) -> ExchangeError:
|
|
345
|
+
body = exc.body if isinstance(exc.body, dict) else {}
|
|
346
|
+
message = str(body.get("message") or exc)
|
|
347
|
+
code = body.get("code")
|
|
348
|
+
text = message.lower()
|
|
349
|
+
if "global rate limit exceeded" in text:
|
|
350
|
+
return OrderRejected(message, reason="latency_stopgap", info=body, body=exc.body, status=exc.status)
|
|
351
|
+
if code == 7 or exc.status == 403:
|
|
352
|
+
return PermissionDenied(message, body=exc.body, status=exc.status)
|
|
353
|
+
if isinstance(exc, AuthenticationError):
|
|
354
|
+
return exc
|
|
355
|
+
if code == 6 or (exc.status == 409 and "clord" in text):
|
|
356
|
+
return DuplicateClientOrderId(message)
|
|
357
|
+
if "buying power" in text or "insufficient" in text:
|
|
358
|
+
return InsufficientFunds(message, body=exc.body, status=exc.status)
|
|
359
|
+
if any(word in text for word in ("market closed", "exchange closed", "instrument closed", "halted", "suspended", "not open")):
|
|
360
|
+
return MarketHalted(message, body=exc.body, status=exc.status)
|
|
361
|
+
if code == 5 or isinstance(exc, MarketNotFound):
|
|
362
|
+
return OrderNotFound(message, body=exc.body, status=exc.status)
|
|
363
|
+
if isinstance(exc, BadRequest) or code in GRPC_CODES:
|
|
364
|
+
return OrderRejected(message, reason=GRPC_CODES.get(code) if isinstance(code, int) else None, info=body, body=exc.body, status=exc.status)
|
|
365
|
+
return exc
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Auth
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
class TokenSource:
|
|
373
|
+
"""Signs the client assertion and keeps a valid access token.
|
|
374
|
+
|
|
375
|
+
One refresh at a time: concurrent callers wait for the same token rather
|
|
376
|
+
than each asking Auth0 for their own.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
def __init__(self, credentials: PolymarketUSExchangeCredentials, http: AsyncHttpClient, *, audience: str, domain: str):
|
|
380
|
+
try:
|
|
381
|
+
import jwt # noqa: F401
|
|
382
|
+
from cryptography.hazmat.primitives import serialization
|
|
383
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
384
|
+
raise ImportError("Polymarket US exchange auth needs PyJWT: pip install synpath") from exc
|
|
385
|
+
self.client_id = credentials.client_id
|
|
386
|
+
self._key = serialization.load_pem_private_key(credentials.private_key_pem, password=None)
|
|
387
|
+
self.http = http
|
|
388
|
+
self.audience = audience
|
|
389
|
+
self.domain = domain
|
|
390
|
+
self._token: str | None = None
|
|
391
|
+
self._expires = 0.0
|
|
392
|
+
self._lock = asyncio.Lock()
|
|
393
|
+
|
|
394
|
+
def assertion(self, *, now: int | None = None) -> str:
|
|
395
|
+
import jwt
|
|
396
|
+
|
|
397
|
+
stamp = int(now if now is not None else time.time())
|
|
398
|
+
claims = {
|
|
399
|
+
"iss": self.client_id, "sub": self.client_id, "aud": f"https://{self.domain}/oauth/token",
|
|
400
|
+
"iat": stamp, "exp": stamp + 300, "jti": str(uuid.uuid4()),
|
|
401
|
+
}
|
|
402
|
+
return jwt.encode(claims, self._key, algorithm="RS256")
|
|
403
|
+
|
|
404
|
+
async def token(self) -> str:
|
|
405
|
+
if self._token and time.monotonic() < self._expires:
|
|
406
|
+
return self._token
|
|
407
|
+
async with self._lock:
|
|
408
|
+
if self._token and time.monotonic() < self._expires:
|
|
409
|
+
return self._token
|
|
410
|
+
raw = await self.http.post(f"https://{self.domain}/oauth/token", json={
|
|
411
|
+
"client_id": self.client_id,
|
|
412
|
+
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
413
|
+
"client_assertion": self.assertion(),
|
|
414
|
+
"audience": self.audience,
|
|
415
|
+
"grant_type": "client_credentials",
|
|
416
|
+
})
|
|
417
|
+
if not raw or not raw.get("access_token"):
|
|
418
|
+
raise CredentialsMissing("polymarket_us: the token endpoint returned no access token")
|
|
419
|
+
self._token = str(raw["access_token"])
|
|
420
|
+
self._expires = time.monotonic() + max(0, int(raw.get("expires_in") or 180) - TOKEN_MARGIN_S)
|
|
421
|
+
return self._token
|
|
422
|
+
|
|
423
|
+
def invalidate(self) -> None:
|
|
424
|
+
self._token, self._expires = None, 0.0
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
# Adapter
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
class PolymarketUSExchangeTrading(TradingExchange):
|
|
432
|
+
"""Polymarket US order entry on the exchange API.
|
|
433
|
+
|
|
434
|
+
```python
|
|
435
|
+
from synpath.trading.credentials import load_credentials, require
|
|
436
|
+
from synpath.trading.polymarket_us_exchange import PolymarketUSExchangeTrading
|
|
437
|
+
|
|
438
|
+
creds = require("polymarket_us_exchange", load_credentials())
|
|
439
|
+
async with PolymarketUSExchangeTrading(creds) as pmx:
|
|
440
|
+
print(await pmx.whoami())
|
|
441
|
+
print(await pmx.fetch_balance())
|
|
442
|
+
```
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
id = VENUE
|
|
446
|
+
name = "Polymarket US (exchange API)"
|
|
447
|
+
native_order_types = NATIVE_ORDER_TYPES
|
|
448
|
+
"""Order types the exchange holds itself, stops included."""
|
|
449
|
+
has: dict[str, Capability] = {
|
|
450
|
+
"create_order": True,
|
|
451
|
+
"create_orders": True,
|
|
452
|
+
"cancel_order": True,
|
|
453
|
+
"cancel_orders": True,
|
|
454
|
+
# No cancel-all endpoint: open orders are listed and cancelled
|
|
455
|
+
# twenty to a request.
|
|
456
|
+
"cancel_all_orders": True,
|
|
457
|
+
"edit_order": True,
|
|
458
|
+
"fetch_order": True,
|
|
459
|
+
"fetch_open_orders": True,
|
|
460
|
+
"fetch_orders": True,
|
|
461
|
+
"fetch_my_trades": True,
|
|
462
|
+
"fetch_positions": True,
|
|
463
|
+
"fetch_balance": True,
|
|
464
|
+
"fetch_settlements": False,
|
|
465
|
+
"fetch_queue_position": False,
|
|
466
|
+
"fetch_fee_estimate": True,
|
|
467
|
+
"rfq": False,
|
|
468
|
+
"split_merge": False,
|
|
469
|
+
"watch_orders": False,
|
|
470
|
+
"watch_my_trades": False,
|
|
471
|
+
"watch_positions": False,
|
|
472
|
+
"watch_balance": False,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
def __init__(
|
|
476
|
+
self,
|
|
477
|
+
credentials: PolymarketUSExchangeCredentials,
|
|
478
|
+
*,
|
|
479
|
+
account_name: str | None = None,
|
|
480
|
+
base_url: str | None = None,
|
|
481
|
+
auth_domain: str | None = None,
|
|
482
|
+
gateway_url: str = GATEWAY_URL,
|
|
483
|
+
limiter: BudgetLimiter | None = None,
|
|
484
|
+
timeout: float = 30.0,
|
|
485
|
+
client: Any = None,
|
|
486
|
+
):
|
|
487
|
+
import httpx
|
|
488
|
+
|
|
489
|
+
env_url, env_domain = ENVIRONMENTS[credentials.env]
|
|
490
|
+
self.credentials = credentials
|
|
491
|
+
self.base_url = (base_url or env_url).rstrip("/")
|
|
492
|
+
shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True)
|
|
493
|
+
self._http = shared
|
|
494
|
+
self.api = AsyncHttpClient(self.base_url, limiter=None, client=shared, venue=VENUE)
|
|
495
|
+
self.gateway = AsyncHttpClient(gateway_url, limiter=None, client=shared, venue=VENUE)
|
|
496
|
+
self.tokens = TokenSource(credentials, self.api, audience=self.base_url, domain=auth_domain or env_domain)
|
|
497
|
+
self.trading_account = credentials.account
|
|
498
|
+
self.account = Account(venue=VENUE, name=account_name or credentials.account or "default")
|
|
499
|
+
# 100 requests a second per firm on a one-minute average; the query
|
|
500
|
+
# endpoints below also have their own, much smaller budgets.
|
|
501
|
+
self.limiter = limiter or BudgetLimiter(read_per_second=50, write_per_second=50)
|
|
502
|
+
self.search_orders_budget = BudgetLimiter(read_per_second=12 / 60, write_per_second=12 / 60, burst_seconds=60, max_wait_s=30)
|
|
503
|
+
self.search_executions_budget = BudgetLimiter(read_per_second=12 / 60, write_per_second=12 / 60, burst_seconds=60, max_wait_s=30)
|
|
504
|
+
self.refdata_budget = BudgetLimiter(read_per_second=6 / 60, write_per_second=6 / 60, burst_seconds=60, max_wait_s=30)
|
|
505
|
+
self._scales: dict[str, InstrumentScale] = {}
|
|
506
|
+
self._gateway_markets: dict[str, dict[str, Any]] = {}
|
|
507
|
+
|
|
508
|
+
# -- transport ------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
async def _call(
|
|
511
|
+
self, method: str, path: str, *, params: Any = None, json: Any = None, kind: str = "write",
|
|
512
|
+
priority: Priority = Priority.NORMAL, budget: BudgetLimiter | None = None, account_scoped: bool = True,
|
|
513
|
+
) -> Any:
|
|
514
|
+
await self.limiter.acquire(cost=1, kind=kind, priority=priority) # type: ignore[arg-type]
|
|
515
|
+
if budget is not None:
|
|
516
|
+
await budget.acquire(cost=1, kind="read")
|
|
517
|
+
for attempt in range(2):
|
|
518
|
+
headers = {"Authorization": f"Bearer {await self.tokens.token()}"}
|
|
519
|
+
if account_scoped:
|
|
520
|
+
headers["x-participant-id"] = self.credentials.participant_id
|
|
521
|
+
try:
|
|
522
|
+
return await self.api.request(method, path, params=params or None, json=json, headers=headers)
|
|
523
|
+
except AuthenticationError as exc:
|
|
524
|
+
if exc.status == 401 and attempt == 0:
|
|
525
|
+
self.tokens.invalidate()
|
|
526
|
+
continue
|
|
527
|
+
raise error_of(exc) from None
|
|
528
|
+
except ExchangeError as exc:
|
|
529
|
+
raise error_of(exc) from None
|
|
530
|
+
raise AssertionError("unreachable")
|
|
531
|
+
|
|
532
|
+
async def _account(self) -> str:
|
|
533
|
+
if not self.trading_account:
|
|
534
|
+
accounts = await self.list_accounts()
|
|
535
|
+
if not accounts:
|
|
536
|
+
raise PermissionDenied(f"polymarket_us: participant {self.credentials.participant_id} has no trading account")
|
|
537
|
+
self.trading_account = accounts[0]
|
|
538
|
+
return self.trading_account
|
|
539
|
+
|
|
540
|
+
# -- identity -------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
async def whoami(self) -> dict[str, Any]:
|
|
543
|
+
return await self._call("GET", "/v1/whoami", kind="read")
|
|
544
|
+
|
|
545
|
+
async def list_accounts(self) -> list[str]:
|
|
546
|
+
raw = await self._call("GET", "/v1/accounts", kind="read")
|
|
547
|
+
return [str(a) for a in (raw or {}).get("accounts") or []]
|
|
548
|
+
|
|
549
|
+
# -- reference data -------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
async def load_instruments(self, symbols: list[str], *, strict: bool = True) -> dict[str, InstrumentScale]:
|
|
552
|
+
"""Scales for these symbols, fetched in one reference-data request and
|
|
553
|
+
cached for the life of the adapter. With `strict`, a symbol reference
|
|
554
|
+
data does not know is an `InvalidOrder`; without, it is left out."""
|
|
555
|
+
missing = [s for s in dict.fromkeys(symbols) if s not in self._scales]
|
|
556
|
+
if missing:
|
|
557
|
+
raw = await self._call(
|
|
558
|
+
"POST", "/v1/refdata/instruments", json={"symbols": missing, "pageSize": max(len(missing), 1)},
|
|
559
|
+
kind="read", budget=self.refdata_budget, account_scoped=False,
|
|
560
|
+
)
|
|
561
|
+
for row in (raw or {}).get("instruments") or []:
|
|
562
|
+
scale = InstrumentScale.from_instrument(row)
|
|
563
|
+
self._scales[scale.symbol] = scale
|
|
564
|
+
unknown = [s for s in symbols if s not in self._scales]
|
|
565
|
+
if unknown and strict:
|
|
566
|
+
raise InvalidOrder(f"polymarket_us: no instrument {unknown[0]!r} in reference data")
|
|
567
|
+
return {s: self._scales[s] for s in symbols if s in self._scales}
|
|
568
|
+
|
|
569
|
+
async def scale_of(self, symbol: str) -> InstrumentScale:
|
|
570
|
+
return (await self.load_instruments([symbol]))[symbol]
|
|
571
|
+
|
|
572
|
+
def cached_scale(self, symbol: str) -> InstrumentScale | None:
|
|
573
|
+
"""A symbol's scales if already read, without asking reference data."""
|
|
574
|
+
return self._scales.get(symbol)
|
|
575
|
+
|
|
576
|
+
def remember_instrument(self, scale: InstrumentScale) -> None:
|
|
577
|
+
self._scales[scale.symbol] = scale
|
|
578
|
+
|
|
579
|
+
# -- orders ---------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
async def create_order(self, request: OrderRequest) -> Order:
|
|
582
|
+
"""Insert one order. Returns it `pending` with the exchange's id; the
|
|
583
|
+
exchange accepts or rejects it asynchronously."""
|
|
584
|
+
symbol = symbol_of(request.market_id)
|
|
585
|
+
scale = await self.scale_of(symbol)
|
|
586
|
+
body = translate_order(request, scale, account=await self._account())
|
|
587
|
+
raw = await self._call("POST", "/v1/trading/orders", json=body)
|
|
588
|
+
return self._pending(str((raw or {}).get("orderId") or ""), request, body, scale)
|
|
589
|
+
|
|
590
|
+
def _pending(self, order_id: str, request: OrderRequest, body: dict[str, Any], scale: InstrumentScale) -> Order:
|
|
591
|
+
order = order_of({**body, "id": order_id, "state": "ORDER_STATE_PENDING_NEW"}, scale, account=request.account or self.account)
|
|
592
|
+
return order.model_copy(update={
|
|
593
|
+
"created_at": int(time.time() * 1000), "book": request.book, "trader": request.trader,
|
|
594
|
+
"tags": request.tags, "info": {"request": body},
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]:
|
|
598
|
+
account = await self._account()
|
|
599
|
+
symbols = []
|
|
600
|
+
for request in requests:
|
|
601
|
+
try:
|
|
602
|
+
symbols.append(symbol_of(request.market_id))
|
|
603
|
+
except InvalidOrder:
|
|
604
|
+
pass
|
|
605
|
+
await self.load_instruments(symbols, strict=False)
|
|
606
|
+
prepared: list[tuple[dict[str, Any], InstrumentScale] | Exception] = []
|
|
607
|
+
for request in requests:
|
|
608
|
+
try:
|
|
609
|
+
symbol = symbol_of(request.market_id)
|
|
610
|
+
scale = self._scales[symbol]
|
|
611
|
+
prepared.append((translate_order(request, scale, account=account), scale))
|
|
612
|
+
except InvalidOrder as exc:
|
|
613
|
+
prepared.append(exc)
|
|
614
|
+
except KeyError:
|
|
615
|
+
prepared.append(InvalidOrder(f"polymarket_us: no instrument for {request.market_id!r} in reference data"))
|
|
616
|
+
results: list[Order | Exception] = [p if isinstance(p, Exception) else p for p in prepared] # type: ignore[misc]
|
|
617
|
+
sendable = [i for i, p in enumerate(prepared) if not isinstance(p, Exception)]
|
|
618
|
+
for start in range(0, len(sendable), BATCH):
|
|
619
|
+
chunk = sendable[start:start + BATCH]
|
|
620
|
+
raw = await self._call("POST", "/v1/trading/orders/list", json={"requests": [prepared[i][0] for i in chunk]}) # type: ignore[index]
|
|
621
|
+
answers = list((raw or {}).get("responses") or [])
|
|
622
|
+
for offset, index in enumerate(chunk):
|
|
623
|
+
body, scale = prepared[index] # type: ignore[misc]
|
|
624
|
+
order_id = (answers[offset] or {}).get("orderId") if offset < len(answers) else None
|
|
625
|
+
results[index] = (
|
|
626
|
+
self._pending(str(order_id), requests[index], body, scale) if order_id
|
|
627
|
+
else OrderRejected("polymarket_us: no order id returned for this entry", reason="no_id")
|
|
628
|
+
)
|
|
629
|
+
return results
|
|
630
|
+
|
|
631
|
+
async def cancel_order(self, order_id: str, *, market_id: str | None = None, current: Order | None = None) -> Order:
|
|
632
|
+
"""Request a cancel. Returns the order `pending_cancel`; pass
|
|
633
|
+
`current` (or `market_id`) to spare the lookup of its symbol."""
|
|
634
|
+
current = current or await self._find_order(order_id, symbol=symbol_of(market_id) if market_id else None)
|
|
635
|
+
await self._call(
|
|
636
|
+
"POST", "/v1/trading/orders/cancel", json={"orderId": order_id, "symbol": symbol_of(current.market_id)},
|
|
637
|
+
priority=Priority.HIGH,
|
|
638
|
+
)
|
|
639
|
+
return current.model_copy(update={"status": OrderStatus.PENDING_CANCEL})
|
|
640
|
+
|
|
641
|
+
async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]:
|
|
642
|
+
open_orders = {o.id: o for o in await self.fetch_open_orders(market_id=market_id)}
|
|
643
|
+
results: dict[str, Order | Exception] = {}
|
|
644
|
+
known = [oid for oid in order_ids if oid in open_orders]
|
|
645
|
+
for oid in order_ids:
|
|
646
|
+
if oid not in open_orders:
|
|
647
|
+
results[oid] = OrderNotFound(f"polymarket_us: {oid} is not an open order")
|
|
648
|
+
for start in range(0, len(known), BATCH):
|
|
649
|
+
chunk = known[start:start + BATCH]
|
|
650
|
+
await self._call(
|
|
651
|
+
"POST", "/v1/trading/orders/cancel/list",
|
|
652
|
+
json={"requests": [{"orderId": oid, "symbol": symbol_of(open_orders[oid].market_id)} for oid in chunk]},
|
|
653
|
+
priority=Priority.HIGH,
|
|
654
|
+
)
|
|
655
|
+
for oid in chunk:
|
|
656
|
+
results[oid] = open_orders[oid].model_copy(update={"status": OrderStatus.PENDING_CANCEL})
|
|
657
|
+
return [results[oid] for oid in order_ids]
|
|
658
|
+
|
|
659
|
+
async def cancel_all_orders(self, *, market_id: str | None = None) -> int:
|
|
660
|
+
"""Every open order, or every one on a symbol: listed, then cancelled
|
|
661
|
+
twenty to a request. Returns how many cancels were sent."""
|
|
662
|
+
orders = await self.fetch_open_orders(market_id=market_id)
|
|
663
|
+
results = await self.cancel_orders([o.id for o in orders], market_id=market_id)
|
|
664
|
+
return sum(1 for r in results if not isinstance(r, Exception))
|
|
665
|
+
|
|
666
|
+
async def edit_order(self, request: EditRequest, *, current: Order | None = None) -> Order:
|
|
667
|
+
"""Cancel-replace price, quantity, time in force or expiry. Returns the
|
|
668
|
+
order `pending_replace`; whether queue priority survives is not
|
|
669
|
+
published, so `queue_priority_preserved` is `None`. Prices are YES
|
|
670
|
+
prices, as the order reads back."""
|
|
671
|
+
current = current or await self._find_order(request.order_id)
|
|
672
|
+
scale = await self.scale_of(symbol_of(current.market_id))
|
|
673
|
+
body: dict[str, Any] = {
|
|
674
|
+
"orderId": request.order_id, "symbol": symbol_of(current.market_id),
|
|
675
|
+
"clordId": request.client_order_id or str(uuid.uuid4()),
|
|
676
|
+
"manualOrderIndicator": "MANUAL_ORDER_INDICATOR_AUTOMATED",
|
|
677
|
+
}
|
|
678
|
+
price = D(request.price) if request.price is not None else current.price
|
|
679
|
+
if price is not None:
|
|
680
|
+
body["price"] = scale.price_to_wire(validate_price(price, scale.precision))
|
|
681
|
+
if current.stop_price is not None:
|
|
682
|
+
body["stopPrice"] = scale.price_to_wire(current.stop_price)
|
|
683
|
+
quantity = validate_amount(D(request.amount), scale.precision) if request.amount is not None else current.amount
|
|
684
|
+
body["orderQty"] = scale.qty_to_wire(quantity)
|
|
685
|
+
tif = request.time_in_force or current.time_in_force
|
|
686
|
+
if tif == TimeInForce.DAY:
|
|
687
|
+
raise InvalidOrder("polymarket_us: 'day' is rewritten to 'gtd' by the engine")
|
|
688
|
+
body["timeInForce"] = TIF_TO_WIRE[tif]
|
|
689
|
+
expires = request.expires_at or current.expires_at
|
|
690
|
+
if tif == TimeInForce.GTD:
|
|
691
|
+
if expires is None:
|
|
692
|
+
raise InvalidOrder("polymarket_us: a gtd order needs expires_at")
|
|
693
|
+
body["goodTillTime"] = rfc3339(expires)
|
|
694
|
+
await self._call("POST", "/v1/trading/orders/replace", json=body)
|
|
695
|
+
return current.model_copy(update={
|
|
696
|
+
"status": OrderStatus.PENDING_REPLACE, "queue_priority_preserved": None,
|
|
697
|
+
"info": {**current.info, "replace": body},
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]:
|
|
701
|
+
params: dict[str, Any] = {"accounts": await self._account()}
|
|
702
|
+
if market_id:
|
|
703
|
+
params["symbols"] = symbol_of(market_id)
|
|
704
|
+
raw = await self._call("GET", "/v1/trading/orders/open", params=params, kind="read")
|
|
705
|
+
rows = (raw or {}).get("orders") or []
|
|
706
|
+
scales = await self.load_instruments([str(r.get("symbol")) for r in rows]) if rows else {}
|
|
707
|
+
return [order_of(r, scales[str(r.get("symbol"))], account=self.account) for r in rows]
|
|
708
|
+
|
|
709
|
+
async def _find_order(self, order_id: str, *, symbol: str | None = None) -> Order:
|
|
710
|
+
for order in await self.fetch_open_orders(market_id=symbol):
|
|
711
|
+
if order.id == order_id:
|
|
712
|
+
return order
|
|
713
|
+
return await self.fetch_order(order_id)
|
|
714
|
+
|
|
715
|
+
async def fetch_order(self, order_id: str) -> Order:
|
|
716
|
+
"""By id: from the open orders when it is still working, otherwise
|
|
717
|
+
from order search, which is rationed to twelve requests a minute."""
|
|
718
|
+
for order in await self.fetch_open_orders():
|
|
719
|
+
if order.id == order_id:
|
|
720
|
+
return order
|
|
721
|
+
page = await self.fetch_orders(order_id=order_id, limit=1)
|
|
722
|
+
if not page:
|
|
723
|
+
raise OrderNotFound(f"polymarket_us: no order {order_id}")
|
|
724
|
+
return page[0]
|
|
725
|
+
|
|
726
|
+
async def fetch_orders(
|
|
727
|
+
self, *, status: str | None = None, market_id: str | None = None, since: int | None = None,
|
|
728
|
+
limit: int | None = None, cursor: str | None = None, order_id: str | None = None,
|
|
729
|
+
) -> Page[Order]:
|
|
730
|
+
"""Order search. `status` is the venue's filter word without its
|
|
731
|
+
prefix: `open`, `closed`, `filled`, `canceled`, `rejected`, `expired`."""
|
|
732
|
+
body: dict[str, Any] = {"accounts": [await self._account()]}
|
|
733
|
+
if status:
|
|
734
|
+
body["orderStateFilter"] = f"ORDER_STATE_FILTER_{status.upper()}"
|
|
735
|
+
for key, value in (("symbol", symbol_of(market_id) if market_id else None), ("orderId", order_id), ("pageToken", cursor), ("pageSize", limit)):
|
|
736
|
+
if value:
|
|
737
|
+
body[key] = value
|
|
738
|
+
if since:
|
|
739
|
+
body["startTime"] = rfc3339(since)
|
|
740
|
+
raw = await self._call("POST", "/v1/report/orders/search", json=body, kind="read", budget=self.search_orders_budget)
|
|
741
|
+
rows = (raw or {}).get("order") or (raw or {}).get("orders") or []
|
|
742
|
+
scales = await self.load_instruments([str(r.get("symbol")) for r in rows]) if rows else {}
|
|
743
|
+
return Page(
|
|
744
|
+
[order_of(r, scales[str(r.get("symbol"))], account=self.account) for r in rows],
|
|
745
|
+
next_cursor=(raw or {}).get("nextPageToken") or None,
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
async def fetch_my_trades(
|
|
749
|
+
self, *, market_id: str | None = None, order_id: str | None = None, since: int | None = None,
|
|
750
|
+
limit: int | None = None, cursor: str | None = None,
|
|
751
|
+
) -> Page[Fill]:
|
|
752
|
+
body: dict[str, Any] = {
|
|
753
|
+
"accounts": [await self._account()],
|
|
754
|
+
"types": ["EXECUTION_TYPE_FILL", "EXECUTION_TYPE_PARTIAL_FILL"],
|
|
755
|
+
"newestFirst": True,
|
|
756
|
+
}
|
|
757
|
+
for key, value in (("symbol", symbol_of(market_id) if market_id else None), ("orderId", order_id), ("pageToken", cursor), ("pageSize", limit)):
|
|
758
|
+
if value:
|
|
759
|
+
body[key] = value
|
|
760
|
+
if since:
|
|
761
|
+
body["startTime"] = rfc3339(since)
|
|
762
|
+
raw = await self._call("POST", "/v1/report/executions/search", json=body, kind="read", budget=self.search_executions_budget)
|
|
763
|
+
rows = (raw or {}).get("executions") or []
|
|
764
|
+
scales = await self.load_instruments([str((r.get("order") or {}).get("symbol")) for r in rows]) if rows else {}
|
|
765
|
+
return Page(
|
|
766
|
+
[fill_of(r, scales[str((r.get("order") or {}).get("symbol"))], account=self.account) for r in rows],
|
|
767
|
+
next_cursor=None if (raw or {}).get("eof") else (raw or {}).get("nextPageToken") or None,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
async def preview_order(self, request: OrderRequest) -> Order:
|
|
771
|
+
symbol = symbol_of(request.market_id)
|
|
772
|
+
scale = await self.scale_of(symbol)
|
|
773
|
+
body = translate_order(request, scale, account=await self._account())
|
|
774
|
+
raw = await self._call("POST", "/v1/trading/orders/preview", json={"request": body}, kind="read")
|
|
775
|
+
return order_of((raw or {}).get("previewOrder") or {}, scale, account=self.account)
|
|
776
|
+
|
|
777
|
+
# -- account --------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]:
|
|
780
|
+
params: dict[str, Any] = {"name": await self._account()}
|
|
781
|
+
if market_id:
|
|
782
|
+
params["symbol"] = symbol_of(market_id)
|
|
783
|
+
raw = await self._call("GET", "/v1/positions", params=params, kind="read")
|
|
784
|
+
rows = (raw or {}).get("positions") or []
|
|
785
|
+
scales = await self.load_instruments([str(r.get("symbol")) for r in rows]) if rows else {}
|
|
786
|
+
positions = [position_of(r, scales[str(r.get("symbol"))], account=self.account) for r in rows]
|
|
787
|
+
return [p for p in positions if p.contracts > 0]
|
|
788
|
+
|
|
789
|
+
async def fetch_balance(self, *, account: Account | None = None) -> Balance:
|
|
790
|
+
raw = await self._call("POST", "/v1/positions/balance", json={"name": await self._account(), "currency": "USD"}, kind="read") or {}
|
|
791
|
+
total = D(raw.get("balance") or "0")
|
|
792
|
+
buying_power = D(raw["buyingPower"]) if raw.get("buyingPower") not in (None, "") else None
|
|
793
|
+
return Balance(
|
|
794
|
+
venue=VENUE, account=account or self.account, currency="USD", total=total,
|
|
795
|
+
available=buying_power if buying_power is not None else total,
|
|
796
|
+
locked=D(raw["openOrders"]) if raw.get("openOrders") not in (None, "") else None,
|
|
797
|
+
buying_power=buying_power, timestamp=parse_ts(raw.get("updateTime")), info=raw,
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, amount: Decimal) -> FeeEstimate:
|
|
801
|
+
"""From the public gateway's market (same symbols): the taker theta and
|
|
802
|
+
the venue's maker rebate, at the YES price."""
|
|
803
|
+
symbol = symbol_of(market_id)
|
|
804
|
+
market = self._gateway_markets.get(symbol)
|
|
805
|
+
if market is None:
|
|
806
|
+
try:
|
|
807
|
+
raw = await self.gateway.get(f"/v1/market/slug/{symbol}")
|
|
808
|
+
except MarketNotFound:
|
|
809
|
+
raise NotSupported(f"polymarket_us: {symbol} is not on the public gateway") from None
|
|
810
|
+
market = self._gateway_markets[symbol] = (raw or {}).get("market") or raw or {}
|
|
811
|
+
schedule = fee_schedule_of(market)
|
|
812
|
+
if schedule is None:
|
|
813
|
+
raise NotSupported(f"polymarket_us: market {symbol} publishes no fee coefficient")
|
|
814
|
+
wire = float(D(price))
|
|
815
|
+
taker = schedule.estimate(wire, float(amount), taker=True)
|
|
816
|
+
maker = schedule.estimate(wire, float(amount), taker=False)
|
|
817
|
+
return FeeEstimate(
|
|
818
|
+
venue=VENUE, market_id=ids.qualify(VENUE, symbol), side=side, price=D(price), amount=D(amount),
|
|
819
|
+
taker_fee=D(str(taker)) if taker is not None else None,
|
|
820
|
+
maker_fee=D(str(maker)) if maker is not None else None,
|
|
821
|
+
currency="USD", info={"schedule": schedule.model_dump()},
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
async def close(self) -> None:
|
|
825
|
+
await self._http.aclose()
|