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,705 @@
|
|
|
1
|
+
"""Polymarket US order entry, on the retail API (`api.polymarket.us`).
|
|
2
|
+
|
|
3
|
+
Polymarket US is a CFTC-regulated exchange with two trading APIs. This is
|
|
4
|
+
the one any verified account can use: a key from polymarket.us/developer,
|
|
5
|
+
Ed25519-signed requests, twenty requests a second. The exchange API for
|
|
6
|
+
onboarded firms (private-key JWT, integer prices, a preprod environment)
|
|
7
|
+
is `polymarket_us_exchange`.
|
|
8
|
+
|
|
9
|
+
Facts that shape the adapter:
|
|
10
|
+
|
|
11
|
+
**One book per market, both outcomes addressable.** The market trades one
|
|
12
|
+
instrument, its YES side; the price on the wire is always the YES price.
|
|
13
|
+
But an order names its outcome and action (`OUTCOME_SIDE_NO` +
|
|
14
|
+
`ORDER_ACTION_BUY` is buying NO), so an order for `{slug}:no` is sent as
|
|
15
|
+
that, priced at `1 - q`, and reads back as the same NO order. Positions
|
|
16
|
+
net: long YES, or short YES (the venue's own word for holding NO), with
|
|
17
|
+
margin rather than a second inventory.
|
|
18
|
+
|
|
19
|
+
**Order entry is asynchronous.** Creating, cancelling or modifying returns
|
|
20
|
+
an id and nothing about the outcome; the matching engine decides after.
|
|
21
|
+
Results here are `pending`, `pending_cancel` or read back, never assumed.
|
|
22
|
+
Batched cancels and modifies echo the ids sent, which the venue says is not
|
|
23
|
+
a confirmation.
|
|
24
|
+
|
|
25
|
+
**No client order id, no reduce-only.** The venue assigns order ids; a
|
|
26
|
+
`client_order_id` is kept on the returned `Order` for the caller's own
|
|
27
|
+
records and is not sent. Reduce-only is refused: the venue offers
|
|
28
|
+
close-position instead.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import base64
|
|
33
|
+
import time
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from decimal import Decimal
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from ..base import AsyncHttpClient, Capability
|
|
40
|
+
from .. import ids
|
|
41
|
+
from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, NotSupported
|
|
42
|
+
from ..polymarket_us import fee_schedule_of, parse_ts
|
|
43
|
+
from ..types import Page
|
|
44
|
+
from .base import TradingExchange
|
|
45
|
+
from .credentials import PolymarketUSCredentials
|
|
46
|
+
from .errors import InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected
|
|
47
|
+
from .limiter import BudgetLimiter, Priority
|
|
48
|
+
from .money import D, complement, validate_amount, validate_price
|
|
49
|
+
from .types import (
|
|
50
|
+
Account, Balance, EditRequest, FeeEstimate, Order, OrderRequest, OrderStatus, OrderType, Position,
|
|
51
|
+
PositionSide, Precision, Settlement, Side, TimeInForce, VENUE_ORDER_TYPES,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
VENUE = "polymarket_us"
|
|
55
|
+
API_URL = "https://api.polymarket.us"
|
|
56
|
+
GATEWAY_URL = "https://gateway.polymarket.us"
|
|
57
|
+
|
|
58
|
+
BATCH = 20
|
|
59
|
+
PRICE_FLOOR = Decimal("0.01")
|
|
60
|
+
PRICE_CEILING = Decimal("0.99")
|
|
61
|
+
"""The exchange's absolute price limits. A price outside them still gets an
|
|
62
|
+
order id and is then rejected, so it is refused here first."""
|
|
63
|
+
|
|
64
|
+
MARKET_TTL_S = 300.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Market rules
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class MarketRules:
|
|
73
|
+
"""What an order on one market must respect, from the public gateway."""
|
|
74
|
+
|
|
75
|
+
slug: str
|
|
76
|
+
tick: Decimal
|
|
77
|
+
min_quantity: Decimal
|
|
78
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
read_at: float = field(default_factory=time.monotonic)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def precision(self) -> Precision:
|
|
83
|
+
whole = self.min_quantity >= 1 and self.min_quantity % 1 == 0
|
|
84
|
+
return Precision(
|
|
85
|
+
tick=self.tick, min_amount=self.min_quantity, amount_step=self.min_quantity, whole_contracts=whole,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_market(cls, market: dict[str, Any]) -> "MarketRules":
|
|
90
|
+
return cls(
|
|
91
|
+
slug=str(market.get("slug") or ""),
|
|
92
|
+
tick=D(str(market.get("orderPriceMinTickSize") or "0.01")),
|
|
93
|
+
min_quantity=D(str(market.get("minimumTradeQty") or "1")),
|
|
94
|
+
raw=market,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Pure translation
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def slug_of(market_id: str) -> str:
|
|
103
|
+
"""The slug behind a Synpath market id (`polymarket_us:<slug>`) or a bare
|
|
104
|
+
slug. Another venue's id is refused before anything is signed."""
|
|
105
|
+
try:
|
|
106
|
+
return ids.native(VENUE, market_id)
|
|
107
|
+
except BadRequest as exc:
|
|
108
|
+
raise InvalidOrder(str(exc)) from None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
TIF_TO_WIRE = {
|
|
112
|
+
TimeInForce.GTC: "TIME_IN_FORCE_GOOD_TILL_CANCEL",
|
|
113
|
+
TimeInForce.GTD: "TIME_IN_FORCE_GOOD_TILL_DATE",
|
|
114
|
+
TimeInForce.IOC: "TIME_IN_FORCE_IMMEDIATE_OR_CANCEL",
|
|
115
|
+
TimeInForce.FOK: "TIME_IN_FORCE_FILL_OR_KILL",
|
|
116
|
+
}
|
|
117
|
+
TIF_FROM_WIRE = {wire: tif for tif, wire in TIF_TO_WIRE.items()} | {"TIME_IN_FORCE_DAY": TimeInForce.DAY}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def rfc3339(ms: int) -> str:
|
|
121
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def number(value: Decimal) -> int | float:
|
|
125
|
+
"""A quantity as the JSON number the venue wants: an integer when whole."""
|
|
126
|
+
return int(value) if value == value.to_integral_value() else float(value)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def usd(value: Decimal) -> dict[str, str]:
|
|
130
|
+
return {"value": str(value), "currency": "USD"}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_prices(price: Decimal, rules: MarketRules) -> Decimal:
|
|
134
|
+
"""Validate a YES price; the wire carries it as given."""
|
|
135
|
+
validate_price(price, rules.precision)
|
|
136
|
+
wire = price
|
|
137
|
+
if not PRICE_FLOOR <= wire <= PRICE_CEILING:
|
|
138
|
+
raise InvalidOrder(
|
|
139
|
+
f"polymarket_us: the YES price would be {wire}, outside the exchange's [{PRICE_FLOOR}, {PRICE_CEILING}]"
|
|
140
|
+
)
|
|
141
|
+
return wire
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def translate_order(request: OrderRequest, rules: MarketRules) -> dict[str, Any]:
|
|
145
|
+
"""An `OrderRequest` as the `POST /v1/orders` body."""
|
|
146
|
+
if request.type not in VENUE_ORDER_TYPES:
|
|
147
|
+
raise InvalidOrder(
|
|
148
|
+
f"polymarket_us: {request.type.value} is held by the execution engine on this API; "
|
|
149
|
+
f"submit it through the engine"
|
|
150
|
+
)
|
|
151
|
+
if request.time_in_force == TimeInForce.DAY:
|
|
152
|
+
raise InvalidOrder(
|
|
153
|
+
"polymarket_us: 'day' is rewritten to 'gtd' by the engine; the venue's own DAY orders do not "
|
|
154
|
+
"cancel at the session roll"
|
|
155
|
+
)
|
|
156
|
+
if request.price is None:
|
|
157
|
+
raise InvalidOrder(
|
|
158
|
+
"polymarket_us: a price is required -- a market order is sent as an immediate limit at the "
|
|
159
|
+
"protection price you give"
|
|
160
|
+
)
|
|
161
|
+
if request.reduce_only:
|
|
162
|
+
raise InvalidOrder("polymarket_us: the venue has no reduce-only flag; use close_position")
|
|
163
|
+
slug = slug_of(request.market_id)
|
|
164
|
+
wire_price = check_prices(D(request.price), rules)
|
|
165
|
+
quantity = validate_amount(D(request.amount), rules.precision)
|
|
166
|
+
if request.type == OrderType.MARKET:
|
|
167
|
+
tif = TimeInForce.FOK if request.time_in_force == TimeInForce.FOK else TimeInForce.IOC
|
|
168
|
+
else:
|
|
169
|
+
tif = request.time_in_force
|
|
170
|
+
if request.post_only and tif not in (TimeInForce.GTC, TimeInForce.GTD):
|
|
171
|
+
raise InvalidOrder("polymarket_us: post-only orders must be gtc or gtd")
|
|
172
|
+
body: dict[str, Any] = {
|
|
173
|
+
"marketSlug": slug,
|
|
174
|
+
"type": "ORDER_TYPE_LIMIT",
|
|
175
|
+
"price": usd(wire_price),
|
|
176
|
+
"quantity": number(quantity),
|
|
177
|
+
"tif": TIF_TO_WIRE[tif],
|
|
178
|
+
# Always the YES leg, as every price in this library is: `sell` is a
|
|
179
|
+
# YES sell, which on this netting venue is the same order as a NO buy.
|
|
180
|
+
"outcomeSide": "OUTCOME_SIDE_YES",
|
|
181
|
+
"action": "ORDER_ACTION_BUY" if request.side == Side.BUY else "ORDER_ACTION_SELL",
|
|
182
|
+
"manualOrderIndicator": (
|
|
183
|
+
"MANUAL_ORDER_INDICATOR_MANUAL" if request.params.get("manual") else "MANUAL_ORDER_INDICATOR_AUTOMATIC"
|
|
184
|
+
),
|
|
185
|
+
}
|
|
186
|
+
if tif == TimeInForce.GTD:
|
|
187
|
+
if request.expires_at is None:
|
|
188
|
+
raise InvalidOrder("polymarket_us: a gtd order needs expires_at")
|
|
189
|
+
body["goodTillTime"] = rfc3339(request.expires_at)
|
|
190
|
+
if request.post_only:
|
|
191
|
+
body["participateDontInitiate"] = True
|
|
192
|
+
return body
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# Normalizers
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
STATE = {
|
|
200
|
+
"ORDER_STATE_PENDING_NEW": OrderStatus.PENDING,
|
|
201
|
+
"ORDER_STATE_PENDING_RISK": OrderStatus.PENDING,
|
|
202
|
+
"ORDER_STATE_NEW": OrderStatus.OPEN,
|
|
203
|
+
"ORDER_STATE_PARTIALLY_FILLED": OrderStatus.OPEN,
|
|
204
|
+
"ORDER_STATE_PENDING_REPLACE": OrderStatus.PENDING_REPLACE,
|
|
205
|
+
"ORDER_STATE_PENDING_CANCEL": OrderStatus.PENDING_CANCEL,
|
|
206
|
+
"ORDER_STATE_FILLED": OrderStatus.CLOSED,
|
|
207
|
+
"ORDER_STATE_CANCELED": OrderStatus.CANCELED,
|
|
208
|
+
"ORDER_STATE_REPLACED": OrderStatus.CANCELED,
|
|
209
|
+
"ORDER_STATE_REJECTED": OrderStatus.REJECTED,
|
|
210
|
+
"ORDER_STATE_EXPIRED": OrderStatus.EXPIRED,
|
|
211
|
+
}
|
|
212
|
+
"""`REPLACED` is the superseded half of a cancel-replace; it will not trade
|
|
213
|
+
again, so it reads as cancelled with the native state kept in `info`."""
|
|
214
|
+
|
|
215
|
+
INTENT = {
|
|
216
|
+
"ORDER_INTENT_BUY_LONG": ("yes", Side.BUY),
|
|
217
|
+
"ORDER_INTENT_SELL_LONG": ("yes", Side.SELL),
|
|
218
|
+
"ORDER_INTENT_BUY_SHORT": ("no", Side.BUY),
|
|
219
|
+
"ORDER_INTENT_SELL_SHORT": ("no", Side.SELL),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _amount(value: Any) -> Decimal | None:
|
|
224
|
+
if isinstance(value, dict):
|
|
225
|
+
value = value.get("value")
|
|
226
|
+
return D(value) if value not in (None, "") else None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def outcome_and_side(raw: dict[str, Any]) -> tuple[str, Side]:
|
|
230
|
+
"""The outcome and action the venue holds the order in."""
|
|
231
|
+
side, action = raw.get("outcomeSide"), raw.get("action")
|
|
232
|
+
if side and action:
|
|
233
|
+
return ("yes" if side == "OUTCOME_SIDE_YES" else "no"), (Side.BUY if action == "ORDER_ACTION_BUY" else Side.SELL)
|
|
234
|
+
if raw.get("intent") in INTENT:
|
|
235
|
+
return INTENT[raw["intent"]]
|
|
236
|
+
return "yes", Side.BUY if raw.get("side") == "ORDER_SIDE_BUY" else Side.SELL
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def yes_leg(outcome: str, side: Side) -> Side:
|
|
240
|
+
"""An order the venue holds as NO, seen from the YES leg: buying NO is
|
|
241
|
+
selling YES and vice versa."""
|
|
242
|
+
if outcome == "yes":
|
|
243
|
+
return side
|
|
244
|
+
return Side.SELL if side == Side.BUY else Side.BUY
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def yes_price(price: Decimal, outcome: str) -> Decimal:
|
|
248
|
+
return price if outcome == "yes" else complement(price)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def order_of(raw: dict[str, Any], *, account: Account | None = None) -> Order:
|
|
252
|
+
"""A venue order on the YES leg: an order the venue holds as a NO buy at
|
|
253
|
+
0.30 reads as a sell at 0.70."""
|
|
254
|
+
slug = str(raw.get("marketSlug") or (raw.get("marketMetadata") or {}).get("slug") or "")
|
|
255
|
+
outcome, venue_side = outcome_and_side(raw)
|
|
256
|
+
side = yes_leg(outcome, venue_side)
|
|
257
|
+
price = _amount(raw.get("price"))
|
|
258
|
+
average = _amount(raw.get("avgPx"))
|
|
259
|
+
amount = D(str(raw.get("quantity") or 0))
|
|
260
|
+
filled = D(str(raw.get("cumQuantity") or 0))
|
|
261
|
+
leaves = raw.get("leavesQuantity")
|
|
262
|
+
status = STATE.get(str(raw.get("state") or ""), OrderStatus.PENDING)
|
|
263
|
+
return Order(
|
|
264
|
+
id=str(raw.get("id") or ""),
|
|
265
|
+
venue=VENUE,
|
|
266
|
+
account=account,
|
|
267
|
+
market_id=ids.qualify(VENUE, slug),
|
|
268
|
+
side=side,
|
|
269
|
+
type=OrderType.MARKET if raw.get("type") == "ORDER_TYPE_MARKET" else OrderType.LIMIT,
|
|
270
|
+
time_in_force=TIF_FROM_WIRE.get(str(raw.get("tif") or ""), TimeInForce.GTC),
|
|
271
|
+
status=status,
|
|
272
|
+
price=price, # the venue already quotes the YES price
|
|
273
|
+
amount=amount,
|
|
274
|
+
filled=filled,
|
|
275
|
+
remaining=D(str(leaves)) if leaves is not None else None,
|
|
276
|
+
average_price=average if average is not None and filled > 0 else None,
|
|
277
|
+
fee=_amount(raw.get("commissionNotionalTotalCollected")),
|
|
278
|
+
fee_currency="USD",
|
|
279
|
+
expires_at=parse_ts(raw.get("goodTillTime")),
|
|
280
|
+
created_at=parse_ts(raw.get("createTime")),
|
|
281
|
+
updated_at=parse_ts(raw.get("insertTime")),
|
|
282
|
+
info=raw,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def pending_order(order_id: str, request: OrderRequest, body: dict[str, Any], account: Account) -> Order:
|
|
287
|
+
"""The order just sent, before the engine has said anything about it."""
|
|
288
|
+
return Order(
|
|
289
|
+
id=order_id, client_order_id=request.client_order_id, venue=VENUE, account=account,
|
|
290
|
+
market_id=ids.qualify(VENUE, body["marketSlug"]), side=request.side,
|
|
291
|
+
type=request.type, time_in_force=TIF_FROM_WIRE[body["tif"]], status=OrderStatus.PENDING,
|
|
292
|
+
price=D(request.price) if request.price is not None else None, amount=D(request.amount),
|
|
293
|
+
post_only=bool(body.get("participateDontInitiate")), expires_at=request.expires_at,
|
|
294
|
+
created_at=int(time.time() * 1000), book=request.book, trader=request.trader, tags=request.tags,
|
|
295
|
+
info={"request": body},
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def position_of(slug: str, raw: dict[str, Any], *, account: Account | None = None) -> Position:
|
|
300
|
+
"""A netted position: long or short the market's YES side."""
|
|
301
|
+
net = D(raw.get("netPositionDecimal") or raw.get("netPosition") or "0")
|
|
302
|
+
cost = _amount(raw.get("cost"))
|
|
303
|
+
contracts = abs(net)
|
|
304
|
+
return Position(
|
|
305
|
+
venue=VENUE,
|
|
306
|
+
account=account,
|
|
307
|
+
market_id=ids.qualify(VENUE, slug),
|
|
308
|
+
side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT,
|
|
309
|
+
contracts=contracts,
|
|
310
|
+
entry_price=(abs(cost) / contracts).quantize(Decimal("0.0001")) if cost is not None and contracts > 0 else None,
|
|
311
|
+
unrealized_pnl=_amount(raw.get("cashValue")),
|
|
312
|
+
realized_pnl=_amount(raw.get("realized")),
|
|
313
|
+
resolved=bool(raw.get("expired")),
|
|
314
|
+
timestamp=parse_ts(raw.get("updateTime")),
|
|
315
|
+
info=raw,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
RESOLUTION_RESULT = {"POSITION_RESOLUTION_SIDE_LONG": "yes", "POSITION_RESOLUTION_SIDE_SHORT": "no"}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def settlement_of(raw: dict[str, Any], *, account: Account | None = None) -> Settlement:
|
|
323
|
+
"""A position resolution from the activity feed. Realized P&L is the change
|
|
324
|
+
across the resolution; `result` follows the venue's resolution side."""
|
|
325
|
+
before = raw.get("beforePosition") or {}
|
|
326
|
+
after = raw.get("afterPosition") or {}
|
|
327
|
+
net = D(before.get("netPositionDecimal") or before.get("netPosition") or "0")
|
|
328
|
+
result = RESOLUTION_RESULT.get(str(raw.get("side") or ""))
|
|
329
|
+
held = "yes" if net > 0 else "no" if net < 0 else None
|
|
330
|
+
realized_before, realized_after = _amount(before.get("realized")), _amount(after.get("realized"))
|
|
331
|
+
slug = str(raw.get("marketSlug") or "")
|
|
332
|
+
return Settlement(
|
|
333
|
+
venue=VENUE,
|
|
334
|
+
account=account,
|
|
335
|
+
market_id=ids.qualify(VENUE, slug),
|
|
336
|
+
held=PositionSide.LONG if held == "yes" else PositionSide.SHORT if held == "no" else None,
|
|
337
|
+
result=result,
|
|
338
|
+
won=(held == result) if held and result else None,
|
|
339
|
+
amount=abs(net) if held else None,
|
|
340
|
+
cost=_amount(before.get("cost")),
|
|
341
|
+
payout=None,
|
|
342
|
+
pnl=(realized_after - realized_before) if realized_after is not None and realized_before is not None else None,
|
|
343
|
+
timestamp=parse_ts(raw.get("updateTime")),
|
|
344
|
+
info=raw,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def balance_of(raw: dict[str, Any], *, account: Account) -> Balance:
|
|
349
|
+
rows = raw.get("balances") or []
|
|
350
|
+
row = next((r for r in rows if str(r.get("currency") or "USD").upper() == "USD"), rows[0] if rows else {})
|
|
351
|
+
total = D(str(row.get("currentBalance") or 0))
|
|
352
|
+
buying_power = D(str(row["buyingPower"])) if row.get("buyingPower") is not None else None
|
|
353
|
+
return Balance(
|
|
354
|
+
venue=VENUE, account=account, currency="USD", total=total,
|
|
355
|
+
available=buying_power if buying_power is not None else total,
|
|
356
|
+
locked=D(str(row["openOrders"])) if row.get("openOrders") is not None else None,
|
|
357
|
+
buying_power=buying_power, timestamp=parse_ts(row.get("lastUpdated")), info=row,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def error_of(exc: ExchangeError) -> ExchangeError:
|
|
362
|
+
body = exc.body if isinstance(exc.body, dict) else {}
|
|
363
|
+
message = str(body.get("message") or body.get("error") or exc)
|
|
364
|
+
text = message.lower()
|
|
365
|
+
if isinstance(exc, AuthenticationError):
|
|
366
|
+
return exc
|
|
367
|
+
if "global rate limit exceeded" in text:
|
|
368
|
+
# The venue's five-second latency stopgap, not a rate limit: safe to resend.
|
|
369
|
+
return OrderRejected(message, reason="latency_stopgap", info=body, body=exc.body, status=exc.status)
|
|
370
|
+
if "buying power" in text or "insufficient" in text:
|
|
371
|
+
return InsufficientFunds(message, body=exc.body, status=exc.status)
|
|
372
|
+
if ("closed" in text and "market" in text) or "halt" in text or "maintenance" in text:
|
|
373
|
+
return MarketHalted(message, body=exc.body, status=exc.status)
|
|
374
|
+
if isinstance(exc, MarketNotFound):
|
|
375
|
+
return OrderNotFound(message, body=exc.body, status=exc.status)
|
|
376
|
+
if isinstance(exc, BadRequest):
|
|
377
|
+
return OrderRejected(message, reason=str(body.get("code") or "") or None, info=body, body=exc.body, status=exc.status)
|
|
378
|
+
return exc
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
# Adapter
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
class PolymarketUSSigner:
|
|
386
|
+
"""Holds the Ed25519 key and signs `timestamp + METHOD + path`, the path
|
|
387
|
+
without its query string, as the venue's SDK does."""
|
|
388
|
+
|
|
389
|
+
def __init__(self, key_id: str, secret_key: str):
|
|
390
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
391
|
+
|
|
392
|
+
raw = base64.b64decode(secret_key)
|
|
393
|
+
if len(raw) not in (32, 64):
|
|
394
|
+
raise InvalidOrder("polymarket_us: the secret key must be a base64 Ed25519 key (32 or 64 bytes)")
|
|
395
|
+
self.key_id = key_id
|
|
396
|
+
self._key = Ed25519PrivateKey.from_private_bytes(raw[:32])
|
|
397
|
+
|
|
398
|
+
def headers(self, method: str, path: str, *, timestamp_ms: int | None = None) -> dict[str, str]:
|
|
399
|
+
stamp = str(timestamp_ms if timestamp_ms is not None else int(time.time() * 1000))
|
|
400
|
+
signature = self._key.sign(f"{stamp}{method.upper()}{path}".encode())
|
|
401
|
+
return {
|
|
402
|
+
"X-PM-Access-Key": self.key_id,
|
|
403
|
+
"X-PM-Timestamp": stamp,
|
|
404
|
+
"X-PM-Signature": base64.b64encode(signature).decode(),
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class PolymarketUSTrading(TradingExchange):
|
|
409
|
+
"""Polymarket US order entry on the retail API.
|
|
410
|
+
|
|
411
|
+
```python
|
|
412
|
+
from synpath.trading.credentials import load_credentials, require
|
|
413
|
+
from synpath.trading.polymarket_us import PolymarketUSTrading
|
|
414
|
+
|
|
415
|
+
creds = require("polymarket_us", load_credentials())
|
|
416
|
+
async with PolymarketUSTrading(creds) as us:
|
|
417
|
+
print(await us.fetch_balance())
|
|
418
|
+
```
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
id = VENUE
|
|
422
|
+
name = "Polymarket US"
|
|
423
|
+
has: dict[str, Capability] = {
|
|
424
|
+
"create_order": True,
|
|
425
|
+
"create_orders": True,
|
|
426
|
+
"cancel_order": True,
|
|
427
|
+
"cancel_orders": True,
|
|
428
|
+
"cancel_all_orders": True,
|
|
429
|
+
"edit_order": True,
|
|
430
|
+
"fetch_order": True,
|
|
431
|
+
"fetch_open_orders": True,
|
|
432
|
+
"fetch_orders": False,
|
|
433
|
+
# The activity feed's trades carry no order id and no side, so they
|
|
434
|
+
# cannot be fills; `fetch_activities` returns them as the venue does.
|
|
435
|
+
"fetch_my_trades": False,
|
|
436
|
+
"fetch_positions": True,
|
|
437
|
+
"fetch_balance": True,
|
|
438
|
+
"fetch_settlements": True,
|
|
439
|
+
"fetch_queue_position": False,
|
|
440
|
+
"fetch_fee_estimate": True,
|
|
441
|
+
# RFQ on this venue quotes combos, which synpath does not model yet.
|
|
442
|
+
"rfq": False,
|
|
443
|
+
"split_merge": False,
|
|
444
|
+
"watch_orders": False,
|
|
445
|
+
"watch_my_trades": False,
|
|
446
|
+
"watch_positions": False,
|
|
447
|
+
"watch_balance": False,
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
def __init__(
|
|
451
|
+
self,
|
|
452
|
+
credentials: PolymarketUSCredentials,
|
|
453
|
+
*,
|
|
454
|
+
account_name: str = "default",
|
|
455
|
+
api_url: str = API_URL,
|
|
456
|
+
gateway_url: str = GATEWAY_URL,
|
|
457
|
+
limiter: BudgetLimiter | None = None,
|
|
458
|
+
timeout: float = 30.0,
|
|
459
|
+
client: Any = None,
|
|
460
|
+
):
|
|
461
|
+
import httpx
|
|
462
|
+
|
|
463
|
+
self.credentials = credentials
|
|
464
|
+
self.signer = PolymarketUSSigner(credentials.key_id, credentials.secret_key)
|
|
465
|
+
self.account = Account(venue=VENUE, name=account_name)
|
|
466
|
+
# Twenty requests a second per key, shared by every endpoint: one lane.
|
|
467
|
+
self.limiter = limiter or BudgetLimiter(read_per_second=20, write_per_second=20, burst_seconds=1)
|
|
468
|
+
shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True)
|
|
469
|
+
self.api = AsyncHttpClient(api_url, limiter=None, client=shared, venue=VENUE)
|
|
470
|
+
self.gateway = AsyncHttpClient(gateway_url, limiter=None, client=shared, venue=VENUE)
|
|
471
|
+
self._http = shared
|
|
472
|
+
self._markets: dict[str, MarketRules] = {}
|
|
473
|
+
|
|
474
|
+
async def _call(
|
|
475
|
+
self, method: str, path: str, *, params: Any = None, json: Any = None, priority: Priority = Priority.NORMAL,
|
|
476
|
+
) -> Any:
|
|
477
|
+
await self.limiter.acquire(cost=1, kind="write", priority=priority)
|
|
478
|
+
headers = self.signer.headers(method, path)
|
|
479
|
+
try:
|
|
480
|
+
return await self.api.request(
|
|
481
|
+
method, path, params=params or None, json=json, headers=headers,
|
|
482
|
+
)
|
|
483
|
+
except ExchangeError as exc:
|
|
484
|
+
raise error_of(exc) from None
|
|
485
|
+
|
|
486
|
+
# -- market rules ---------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
async def market_rules(self, slug: str, *, refresh: bool = False) -> MarketRules:
|
|
489
|
+
cached = self._markets.get(slug)
|
|
490
|
+
if cached and not refresh and time.monotonic() - cached.read_at < MARKET_TTL_S:
|
|
491
|
+
return cached
|
|
492
|
+
try:
|
|
493
|
+
raw = await self.gateway.get(f"/v1/market/slug/{slug}")
|
|
494
|
+
except MarketNotFound:
|
|
495
|
+
raise InvalidOrder(f"polymarket_us: no market {slug!r}") from None
|
|
496
|
+
rules = MarketRules.from_market((raw or {}).get("market") or raw or {})
|
|
497
|
+
self._markets[slug] = rules
|
|
498
|
+
return rules
|
|
499
|
+
|
|
500
|
+
def remember_market(self, rules: MarketRules) -> None:
|
|
501
|
+
self._markets[rules.slug] = rules
|
|
502
|
+
|
|
503
|
+
# -- orders ---------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
async def create_order(self, request: OrderRequest, *, rules: MarketRules | None = None) -> Order:
|
|
506
|
+
"""Send one order. The result is `pending`: the venue answers with an
|
|
507
|
+
id, and whether the order rests, fills or is rejected comes after."""
|
|
508
|
+
slug = slug_of(request.market_id)
|
|
509
|
+
body = translate_order(request, rules or await self.market_rules(slug))
|
|
510
|
+
raw = await self._call("POST", "/v1/orders", json=body)
|
|
511
|
+
executions = (raw or {}).get("executions") or []
|
|
512
|
+
if executions and executions[-1].get("order"):
|
|
513
|
+
order = order_of(executions[-1]["order"], account=request.account or self.account)
|
|
514
|
+
return order.model_copy(update={"client_order_id": request.client_order_id})
|
|
515
|
+
return pending_order(str((raw or {}).get("id") or ""), request, body, request.account or self.account)
|
|
516
|
+
|
|
517
|
+
async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]:
|
|
518
|
+
"""Twenty to a request. The gateway rejects a whole batch if any entry
|
|
519
|
+
is malformed, so every entry is validated here first and the refused
|
|
520
|
+
ones never join a batch."""
|
|
521
|
+
prepared: list[dict[str, Any] | Exception] = []
|
|
522
|
+
for request in requests:
|
|
523
|
+
try:
|
|
524
|
+
slug = slug_of(request.market_id)
|
|
525
|
+
prepared.append(translate_order(request, await self.market_rules(slug)))
|
|
526
|
+
except InvalidOrder as exc:
|
|
527
|
+
prepared.append(exc)
|
|
528
|
+
results: list[Order | Exception] = list(prepared) # type: ignore[arg-type]
|
|
529
|
+
sendable = [i for i, p in enumerate(prepared) if isinstance(p, dict)]
|
|
530
|
+
for start in range(0, len(sendable), BATCH):
|
|
531
|
+
chunk = sendable[start:start + BATCH]
|
|
532
|
+
raw = await self._call("POST", "/v1/orders/batched", json={"orders": [prepared[i] for i in chunk]})
|
|
533
|
+
ids = list((raw or {}).get("createdOrderIds") or [])
|
|
534
|
+
for offset, index in enumerate(chunk):
|
|
535
|
+
if offset < len(ids) and ids[offset]:
|
|
536
|
+
results[index] = pending_order(str(ids[offset]), requests[index], prepared[index], requests[index].account or self.account) # type: ignore[arg-type]
|
|
537
|
+
else:
|
|
538
|
+
results[index] = OrderRejected("polymarket_us: no order id returned for this entry", reason="no_id")
|
|
539
|
+
return results
|
|
540
|
+
|
|
541
|
+
async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order:
|
|
542
|
+
"""Ask for a cancel and read the order back; it is usually
|
|
543
|
+
`pending_cancel` or already `canceled` by then."""
|
|
544
|
+
slug = slug_of(market_id) if market_id else slug_of((await self.fetch_order(order_id)).market_id)
|
|
545
|
+
await self._call("POST", f"/v1/order/{order_id}/cancel", json={"marketSlug": slug}, priority=Priority.HIGH)
|
|
546
|
+
return await self.fetch_order(order_id)
|
|
547
|
+
|
|
548
|
+
async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]:
|
|
549
|
+
"""Cancel by id, twenty to a request. The venue echoes the ids rather
|
|
550
|
+
than confirming them, so each comes back `pending_cancel`; an id not
|
|
551
|
+
among the open orders comes back `OrderNotFound` without being sent."""
|
|
552
|
+
open_orders = {o.id: o for o in await self.fetch_open_orders(market_id=market_id)}
|
|
553
|
+
results: dict[str, Order | Exception] = {}
|
|
554
|
+
entries = []
|
|
555
|
+
for oid in order_ids:
|
|
556
|
+
if oid in open_orders:
|
|
557
|
+
entries.append({"orderId": oid, "marketSlug": slug_of(open_orders[oid].market_id)})
|
|
558
|
+
else:
|
|
559
|
+
results[oid] = OrderNotFound(f"polymarket_us: {oid} is not an open order")
|
|
560
|
+
for start in range(0, len(entries), BATCH):
|
|
561
|
+
chunk = entries[start:start + BATCH]
|
|
562
|
+
await self._call("POST", "/v1/orders/batched/cancel", json={"orders": chunk}, priority=Priority.HIGH)
|
|
563
|
+
for entry in chunk:
|
|
564
|
+
results[entry["orderId"]] = open_orders[entry["orderId"]].model_copy(update={"status": OrderStatus.PENDING_CANCEL})
|
|
565
|
+
return [results[oid] for oid in order_ids]
|
|
566
|
+
|
|
567
|
+
async def cancel_all_orders(self, *, market_id: str | None = None) -> int:
|
|
568
|
+
body = {"slugs": [slug_of(market_id)]} if market_id else {}
|
|
569
|
+
raw = await self._call("POST", "/v1/orders/open/cancel", json=body, priority=Priority.HIGH)
|
|
570
|
+
return len((raw or {}).get("canceledOrderIds") or [])
|
|
571
|
+
|
|
572
|
+
async def edit_order(self, request: EditRequest, *, current: Order | None = None) -> Order:
|
|
573
|
+
"""Modify price, quantity, time in force or expiry in place.
|
|
574
|
+
|
|
575
|
+
The venue forwards a modify to the exchange as a cancel-replace, and
|
|
576
|
+
does not say whether queue priority survives, so
|
|
577
|
+
`queue_priority_preserved` is `None`. Prices are in the order's own
|
|
578
|
+
outcome terms; the quantity is the order's new total.
|
|
579
|
+
"""
|
|
580
|
+
current = current or await self.fetch_order(request.order_id)
|
|
581
|
+
slug = slug_of(current.market_id)
|
|
582
|
+
rules = await self.market_rules(slug)
|
|
583
|
+
price = D(request.price) if request.price is not None else current.price
|
|
584
|
+
if price is None:
|
|
585
|
+
raise InvalidOrder("polymarket_us: the order has no price to keep")
|
|
586
|
+
quantity = validate_amount(D(request.amount), rules.precision) if request.amount is not None else current.amount
|
|
587
|
+
tif = request.time_in_force or current.time_in_force
|
|
588
|
+
if tif == TimeInForce.DAY:
|
|
589
|
+
raise InvalidOrder("polymarket_us: 'day' is rewritten to 'gtd' by the engine")
|
|
590
|
+
body: dict[str, Any] = {
|
|
591
|
+
"marketSlug": slug,
|
|
592
|
+
"price": usd(check_prices(price, rules)),
|
|
593
|
+
"quantity": number(quantity),
|
|
594
|
+
"tif": TIF_TO_WIRE[tif],
|
|
595
|
+
}
|
|
596
|
+
expires = request.expires_at or current.expires_at
|
|
597
|
+
if tif == TimeInForce.GTD:
|
|
598
|
+
if expires is None:
|
|
599
|
+
raise InvalidOrder("polymarket_us: a gtd order needs expires_at")
|
|
600
|
+
body["goodTillTime"] = rfc3339(expires)
|
|
601
|
+
await self._call("POST", f"/v1/order/{request.order_id}/modify", json=body)
|
|
602
|
+
order = await self.fetch_order(request.order_id)
|
|
603
|
+
return order.model_copy(update={"queue_priority_preserved": None, "info": {**order.info, "modify": body}})
|
|
604
|
+
|
|
605
|
+
async def fetch_order(self, order_id: str) -> Order:
|
|
606
|
+
raw = await self._call("GET", f"/v1/order/{order_id}")
|
|
607
|
+
if not raw or not (raw.get("order") or {}).get("id"):
|
|
608
|
+
raise OrderNotFound(f"polymarket_us: no order {order_id}")
|
|
609
|
+
return order_of(raw["order"], account=self.account)
|
|
610
|
+
|
|
611
|
+
async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]:
|
|
612
|
+
raw = await self._call("GET", "/v1/orders/open", params={"slugs": slug_of(market_id)} if market_id else None)
|
|
613
|
+
return [order_of(row, account=self.account) for row in (raw or {}).get("orders") or []]
|
|
614
|
+
|
|
615
|
+
async def preview_order(self, request: OrderRequest) -> Order:
|
|
616
|
+
"""The venue's own validation and expected result, without placing anything."""
|
|
617
|
+
slug = slug_of(request.market_id)
|
|
618
|
+
body = translate_order(request, await self.market_rules(slug))
|
|
619
|
+
raw = await self._call("POST", "/v1/order/preview", json={"request": body})
|
|
620
|
+
return order_of((raw or {}).get("order") or {}, account=self.account)
|
|
621
|
+
|
|
622
|
+
async def close_position(
|
|
623
|
+
self, market_id: str, *, slippage_ticks: int | None = None, reference_price: Decimal | None = None,
|
|
624
|
+
) -> Order:
|
|
625
|
+
"""Sell everything held in a market at market, within an optional
|
|
626
|
+
slippage band of `slippage_ticks` from `reference_price` (a YES price)."""
|
|
627
|
+
slug = slug_of(market_id)
|
|
628
|
+
body: dict[str, Any] = {"marketSlug": slug, "manualOrderIndicator": "MANUAL_ORDER_INDICATOR_AUTOMATIC"}
|
|
629
|
+
if slippage_ticks is not None:
|
|
630
|
+
if reference_price is None:
|
|
631
|
+
raise InvalidOrder("polymarket_us: a slippage band needs the reference price it is measured from")
|
|
632
|
+
body["slippageTolerance"] = {"currentPrice": usd(D(reference_price)), "ticks": int(slippage_ticks)}
|
|
633
|
+
raw = await self._call("POST", "/v1/order/close-position", json=body)
|
|
634
|
+
executions = (raw or {}).get("executions") or []
|
|
635
|
+
if executions and executions[-1].get("order"):
|
|
636
|
+
return order_of(executions[-1]["order"], account=self.account)
|
|
637
|
+
return Order(
|
|
638
|
+
id=str((raw or {}).get("id") or ""), venue=VENUE, account=self.account,
|
|
639
|
+
market_id=ids.qualify(VENUE, slug), side=Side.SELL, type=OrderType.MARKET,
|
|
640
|
+
time_in_force=TimeInForce.IOC, status=OrderStatus.PENDING, amount=Decimal("0"),
|
|
641
|
+
info={"request": body, "close_position": True},
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
# -- account --------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]:
|
|
647
|
+
positions: list[Position] = []
|
|
648
|
+
cursor: str | None = None
|
|
649
|
+
while True:
|
|
650
|
+
raw = await self._call("GET", "/v1/portfolio/positions", params={
|
|
651
|
+
k: v for k, v in {"market": slug_of(market_id) if market_id else None, "limit": 100, "cursor": cursor}.items() if v is not None
|
|
652
|
+
})
|
|
653
|
+
for slug, row in ((raw or {}).get("positions") or {}).items():
|
|
654
|
+
positions.append(position_of(slug, row, account=self.account))
|
|
655
|
+
cursor = (raw or {}).get("nextCursor") or None
|
|
656
|
+
if (raw or {}).get("eof") or not cursor:
|
|
657
|
+
return [p for p in positions if p.contracts > 0]
|
|
658
|
+
|
|
659
|
+
async def fetch_balance(self, *, account: Account | None = None) -> Balance:
|
|
660
|
+
return balance_of(await self._call("GET", "/v1/account/balances") or {}, account=account or self.account)
|
|
661
|
+
|
|
662
|
+
async def fetch_activities(
|
|
663
|
+
self, *, types: list[str] | None = None, market_id: str | None = None, limit: int | None = None,
|
|
664
|
+
cursor: str | None = None, newest_first: bool = True,
|
|
665
|
+
) -> Page:
|
|
666
|
+
"""The venue's activity feed as it sends it: trades, resolutions,
|
|
667
|
+
deposits and withdrawals, rebates."""
|
|
668
|
+
params = {
|
|
669
|
+
"types": types, "marketSlug": slug_of(market_id) if market_id else None, "limit": limit, "cursor": cursor,
|
|
670
|
+
"sortOrder": "SORT_ORDER_DESCENDING" if newest_first else "SORT_ORDER_ASCENDING",
|
|
671
|
+
}
|
|
672
|
+
raw = await self._call("GET", "/v1/portfolio/activities", params={k: v for k, v in params.items() if v is not None})
|
|
673
|
+
return Page(list((raw or {}).get("activities") or []), next_cursor=None if (raw or {}).get("eof") else (raw or {}).get("nextCursor"))
|
|
674
|
+
|
|
675
|
+
async def fetch_settlements(
|
|
676
|
+
self, *, market_id: str | None = None, since: int | None = None, limit: int | None = None, cursor: str | None = None,
|
|
677
|
+
) -> Page[Settlement]:
|
|
678
|
+
page = await self.fetch_activities(
|
|
679
|
+
types=["ACTIVITY_TYPE_POSITION_RESOLUTION"], market_id=market_id, limit=limit, cursor=cursor,
|
|
680
|
+
)
|
|
681
|
+
rows = [settlement_of(a.get("positionResolution") or {}, account=self.account) for a in page]
|
|
682
|
+
if since:
|
|
683
|
+
rows = [r for r in rows if (r.timestamp or 0) >= since]
|
|
684
|
+
return Page(rows, next_cursor=page.next_cursor)
|
|
685
|
+
|
|
686
|
+
async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, amount: Decimal) -> FeeEstimate:
|
|
687
|
+
"""The market's published taker theta and the venue's maker rebate at
|
|
688
|
+
the YES `price` for `amount` contracts. A negative maker fee is a rebate."""
|
|
689
|
+
slug = slug_of(market_id)
|
|
690
|
+
rules = await self.market_rules(slug)
|
|
691
|
+
schedule = fee_schedule_of(rules.raw)
|
|
692
|
+
if schedule is None:
|
|
693
|
+
raise NotSupported(f"polymarket_us: market {slug} publishes no fee coefficient")
|
|
694
|
+
wire = float(D(price))
|
|
695
|
+
taker = schedule.estimate(wire, float(amount), taker=True)
|
|
696
|
+
maker = schedule.estimate(wire, float(amount), taker=False)
|
|
697
|
+
return FeeEstimate(
|
|
698
|
+
venue=VENUE, market_id=ids.qualify(VENUE, slug), side=side, price=D(price), amount=D(amount),
|
|
699
|
+
taker_fee=D(str(taker)) if taker is not None else None,
|
|
700
|
+
maker_fee=D(str(maker)) if maker is not None else None,
|
|
701
|
+
currency="USD", info={"schedule": schedule.model_dump()},
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
async def close(self) -> None:
|
|
705
|
+
await self._http.aclose()
|