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
synpath/ws/polymarket.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Polymarket's CLOB WebSockets: the public market channel and the user channel.
|
|
2
|
+
|
|
3
|
+
**Market channel.** Subscribing to token ids brings a `book` snapshot for
|
|
4
|
+
each, then `price_change` messages that set a level's absolute size. There
|
|
5
|
+
are no sequence numbers. What there is: every change carries the venue's
|
|
6
|
+
best bid and ask for that token. Those figures describe the book after
|
|
7
|
+
*every* change with the same timestamp -- one trade can arrive as two
|
|
8
|
+
messages stamped alike, and the first message's top of book already
|
|
9
|
+
reflects the second -- so the stream applies a timestamp's changes and
|
|
10
|
+
checks its own top of book against the venue's only once a later timestamp
|
|
11
|
+
arrives for that token (or half a second passes on the feed). A
|
|
12
|
+
disagreement means a message was missed: the stream reports a gap and
|
|
13
|
+
resubscribes the token, which brings a fresh snapshot. Heartbeat is the
|
|
14
|
+
text frame `PING` every ten seconds.
|
|
15
|
+
|
|
16
|
+
**User channel.** Authenticated with the CLOB API credentials in the
|
|
17
|
+
subscription frame. Order events (placement, update, cancellation) and trade
|
|
18
|
+
events that repeat as a trade's settlement moves from `MATCHED` through
|
|
19
|
+
`MINED` to `CONFIRMED` or `FAILED`. Nothing is replayed after a reconnect.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from decimal import Decimal
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from .. import ids
|
|
29
|
+
from ..base import Capability
|
|
30
|
+
from ..trading.polymarket import GAMMA_URL, MarketTokens, fills_of, order_of
|
|
31
|
+
from ..trading.types import Account, OrderStatus, Side
|
|
32
|
+
from .base import (
|
|
33
|
+
ONE,
|
|
34
|
+
BookEvent, BookLevel, D, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, QuoteEvent, Stream,
|
|
35
|
+
TradeEvent, VenueEvent, maybe_D,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
VENUE = "polymarket"
|
|
39
|
+
MARKET_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
|
|
40
|
+
USER_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/user"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PolymarketMarketStream(Stream):
|
|
44
|
+
"""Books, trades and market lifecycle for a set of tokens. No credentials."""
|
|
45
|
+
|
|
46
|
+
venue = VENUE
|
|
47
|
+
name = "market"
|
|
48
|
+
app_ping = "PING"
|
|
49
|
+
data_heartbeat = True
|
|
50
|
+
has: dict[str, Capability] = {
|
|
51
|
+
"watch_order_book": True,
|
|
52
|
+
"watch_ticker": True,
|
|
53
|
+
"watch_trades": True,
|
|
54
|
+
# New markets and resolutions, for the tokens subscribed; the venue
|
|
55
|
+
# publishes no pause or halt on this channel.
|
|
56
|
+
"watch_market_status": "partial",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def __init__(self, *, url: str = MARKET_URL, gamma_url: str = GAMMA_URL, **kwargs: Any):
|
|
60
|
+
super().__init__(url, **kwargs)
|
|
61
|
+
self.tokens: set[str] = set()
|
|
62
|
+
self.books: dict[str, LocalBook] = {}
|
|
63
|
+
self.markets: dict[str, str] = {}
|
|
64
|
+
"""Token id -> condition id, learned from the stream."""
|
|
65
|
+
self.catalog = TokenCatalog(gamma_url)
|
|
66
|
+
"""Which token is which side of which market, from the Gamma catalog."""
|
|
67
|
+
self._unverified: dict[str, tuple[int | None, Any, Any]] = {}
|
|
68
|
+
"""Token -> (timestamp, venue best bid, venue best ask) not yet checked."""
|
|
69
|
+
|
|
70
|
+
async def watch_order_book(self, market_ids: list[str]) -> None:
|
|
71
|
+
"""Books, trades, top of book and lifecycle for these markets, both
|
|
72
|
+
sides: the market channel sends all of them for whatever token it is
|
|
73
|
+
subscribed to. Each market not yet known costs one catalog read."""
|
|
74
|
+
token_ids = []
|
|
75
|
+
for market_id in market_ids:
|
|
76
|
+
tokens = await self.catalog.resolve(market_id)
|
|
77
|
+
token_ids += [tokens.yes_token, tokens.no_token]
|
|
78
|
+
await self.watch_tokens(token_ids)
|
|
79
|
+
|
|
80
|
+
async def watch_tokens(self, token_ids: list[str]) -> None:
|
|
81
|
+
"""Subscribe by CLOB token id, for a caller that already holds them."""
|
|
82
|
+
new = [t for t in token_ids if t not in self.tokens]
|
|
83
|
+
if not new:
|
|
84
|
+
return
|
|
85
|
+
self.tokens.update(new)
|
|
86
|
+
for token in new:
|
|
87
|
+
self.books.setdefault(token, LocalBook())
|
|
88
|
+
await self.send({"assets_ids": new, "operation": "subscribe"})
|
|
89
|
+
|
|
90
|
+
watch_trades = watch_order_book
|
|
91
|
+
watch_ticker = watch_order_book
|
|
92
|
+
|
|
93
|
+
async def unwatch(self, market_ids: list[str]) -> None:
|
|
94
|
+
token_ids = []
|
|
95
|
+
for market_id in market_ids:
|
|
96
|
+
tokens = await self.catalog.resolve(market_id)
|
|
97
|
+
token_ids += [tokens.yes_token, tokens.no_token]
|
|
98
|
+
gone = [t for t in token_ids if t in self.tokens]
|
|
99
|
+
self.tokens.difference_update(gone)
|
|
100
|
+
for token in gone:
|
|
101
|
+
self.books.pop(token, None)
|
|
102
|
+
if gone:
|
|
103
|
+
await self.send({"assets_ids": gone, "operation": "unsubscribe"})
|
|
104
|
+
|
|
105
|
+
async def on_connect(self) -> None:
|
|
106
|
+
if self.tokens:
|
|
107
|
+
await self.send({"assets_ids": sorted(self.tokens), "type": "market", "custom_feature_enabled": True})
|
|
108
|
+
|
|
109
|
+
def on_disconnect(self) -> None:
|
|
110
|
+
self._unverified.clear()
|
|
111
|
+
for book in self.books.values():
|
|
112
|
+
book.invalidate()
|
|
113
|
+
|
|
114
|
+
def book(self, market_id: str, side: str = "yes") -> LocalBook | None:
|
|
115
|
+
"""The local book for a market on either side; `None` if not watched.
|
|
116
|
+
Both sides are real books here, not mirrors."""
|
|
117
|
+
tokens = self.catalog.cached(market_id)
|
|
118
|
+
if tokens is None:
|
|
119
|
+
return None
|
|
120
|
+
return self.books.get(tokens.token(side))
|
|
121
|
+
|
|
122
|
+
def _where(self, token: str, condition: str) -> tuple[str, str]:
|
|
123
|
+
"""`(market_id, side)` for a token the stream reported."""
|
|
124
|
+
tokens = self.catalog.cached(token)
|
|
125
|
+
if tokens is not None:
|
|
126
|
+
return tokens.market_id, tokens.outcome_of(token)
|
|
127
|
+
return ids.qualify(VENUE, condition), "yes"
|
|
128
|
+
|
|
129
|
+
# -- reading --------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def handle(self, message: Any) -> list[Event]:
|
|
132
|
+
if isinstance(message, list):
|
|
133
|
+
events: list[Event] = []
|
|
134
|
+
for item in message:
|
|
135
|
+
events.extend(self.handle(item))
|
|
136
|
+
return events
|
|
137
|
+
if not isinstance(message, dict):
|
|
138
|
+
return [] # PONG
|
|
139
|
+
kind = message.get("event_type")
|
|
140
|
+
if kind == "book":
|
|
141
|
+
return self._on_book(message)
|
|
142
|
+
if kind == "price_change":
|
|
143
|
+
return self._on_price_change(message)
|
|
144
|
+
if kind == "last_trade_price":
|
|
145
|
+
token = str(message.get("asset_id") or "")
|
|
146
|
+
market_id, side = self._where(token, str(message.get("market") or ""))
|
|
147
|
+
price = D(message["price"])
|
|
148
|
+
taker = str(message.get("side") or "").upper()
|
|
149
|
+
taker_side = Side.BUY if taker == "BUY" else Side.SELL if taker == "SELL" else None
|
|
150
|
+
if side == "no":
|
|
151
|
+
price = ONE - price
|
|
152
|
+
taker_side = {Side.BUY: Side.SELL, Side.SELL: Side.BUY}.get(taker_side) # type: ignore[arg-type]
|
|
153
|
+
return [TradeEvent(
|
|
154
|
+
venue=VENUE, market_id=market_id,
|
|
155
|
+
id=message.get("transaction_hash") or None, price=price, amount=D(message["size"]),
|
|
156
|
+
taker_side=taker_side, timestamp=_ms(message.get("timestamp")), info=message,
|
|
157
|
+
)]
|
|
158
|
+
if kind == "best_bid_ask":
|
|
159
|
+
token = str(message.get("asset_id") or "")
|
|
160
|
+
market_id, side = self._where(token, str(message.get("market") or ""))
|
|
161
|
+
return [QuoteEvent(
|
|
162
|
+
venue=VENUE, market_id=market_id, side=side, # type: ignore[arg-type]
|
|
163
|
+
bid=maybe_D(message.get("best_bid")), ask=maybe_D(message.get("best_ask")),
|
|
164
|
+
timestamp=_ms(message.get("timestamp")), info=message,
|
|
165
|
+
)]
|
|
166
|
+
if kind == "tick_size_change":
|
|
167
|
+
return [VenueEvent(venue=VENUE, name="tick_size_change", payload=message, timestamp=_ms(message.get("timestamp")))]
|
|
168
|
+
if kind == "new_market":
|
|
169
|
+
return [MarketStatusEvent(
|
|
170
|
+
venue=VENUE, market_id=self._market_of(str(message.get("market") or "")), state="created", native=kind,
|
|
171
|
+
timestamp=_ms(message.get("timestamp")), info=message,
|
|
172
|
+
)]
|
|
173
|
+
if kind == "market_resolved":
|
|
174
|
+
return [MarketStatusEvent(
|
|
175
|
+
venue=VENUE, market_id=self._market_of(str(message.get("market") or "")), state="determined", native=kind,
|
|
176
|
+
result=message.get("winning_asset_id") or None, timestamp=_ms(message.get("timestamp")), info=message,
|
|
177
|
+
)]
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
def _on_book(self, message: dict[str, Any]) -> list[Event]:
|
|
181
|
+
token = str(message.get("asset_id") or "")
|
|
182
|
+
market = str(message.get("market") or "")
|
|
183
|
+
self.markets[token] = market
|
|
184
|
+
book = self.books.setdefault(token, LocalBook())
|
|
185
|
+
recovering = not book.ready and book.timestamp is not None
|
|
186
|
+
book.replace(
|
|
187
|
+
[(D(level["price"]), D(level["size"])) for level in message.get("bids") or []],
|
|
188
|
+
[(D(level["price"]), D(level["size"])) for level in message.get("asks") or []],
|
|
189
|
+
)
|
|
190
|
+
book.timestamp = _ms(message.get("timestamp"))
|
|
191
|
+
self._unverified.pop(token, None)
|
|
192
|
+
if recovering:
|
|
193
|
+
self.status("resynced", f"book {token}", key=token)
|
|
194
|
+
bids, asks = book.levels()
|
|
195
|
+
market_id, side = self._where(token, market)
|
|
196
|
+
return [BookEvent(
|
|
197
|
+
venue=VENUE, market_id=market_id, side=side, kind="snapshot", bids=bids, asks=asks, # type: ignore[arg-type]
|
|
198
|
+
best_bid=book.best_bid, best_ask=book.best_ask, timestamp=book.timestamp,
|
|
199
|
+
info={"hash": message.get("hash"), "tick_size": message.get("tick_size"), "last_trade_price": message.get("last_trade_price")},
|
|
200
|
+
)]
|
|
201
|
+
|
|
202
|
+
def _on_price_change(self, message: dict[str, Any]) -> list[Event]:
|
|
203
|
+
market = str(message.get("market") or "")
|
|
204
|
+
stamp = _ms(message.get("timestamp"))
|
|
205
|
+
self._verify_older_than(stamp)
|
|
206
|
+
changed: dict[str, tuple[list[BookLevel], list[BookLevel]]] = {}
|
|
207
|
+
for change in message.get("price_changes") or []:
|
|
208
|
+
token = str(change.get("asset_id") or "")
|
|
209
|
+
book = self.books.get(token)
|
|
210
|
+
if book is None or not book.ready:
|
|
211
|
+
continue
|
|
212
|
+
pending = self._unverified.get(token)
|
|
213
|
+
if pending is not None and pending[0] != stamp and not self._verify(token):
|
|
214
|
+
continue
|
|
215
|
+
price, size = D(change["price"]), D(change["size"])
|
|
216
|
+
side = "bid" if str(change.get("side")).upper() == "BUY" else "ask"
|
|
217
|
+
new = book.set(side, price, size)
|
|
218
|
+
bids, asks = changed.setdefault(token, ([], []))
|
|
219
|
+
(bids if side == "bid" else asks).append(BookLevel(price, new))
|
|
220
|
+
self._unverified[token] = (stamp, change.get("best_bid"), change.get("best_ask"))
|
|
221
|
+
book.timestamp = stamp
|
|
222
|
+
events: list[Event] = []
|
|
223
|
+
for token, (bids, asks) in changed.items():
|
|
224
|
+
market_id, side = self._where(token, market)
|
|
225
|
+
events.append(BookEvent(
|
|
226
|
+
venue=VENUE, market_id=market_id, side=side, kind="delta", bids=tuple(bids), asks=tuple(asks), # type: ignore[arg-type]
|
|
227
|
+
best_bid=self.books[token].best_bid, best_ask=self.books[token].best_ask, timestamp=stamp,
|
|
228
|
+
))
|
|
229
|
+
return events
|
|
230
|
+
|
|
231
|
+
def _market_of(self, condition: str) -> str:
|
|
232
|
+
tokens = self.catalog.cached(condition)
|
|
233
|
+
return tokens.market_id if tokens is not None else ids.qualify(VENUE, condition)
|
|
234
|
+
|
|
235
|
+
def _verify_older_than(self, stamp: int | None, window_ms: int = 500) -> None:
|
|
236
|
+
if stamp is None:
|
|
237
|
+
return
|
|
238
|
+
for token, (pending_stamp, _, _) in list(self._unverified.items()):
|
|
239
|
+
if pending_stamp is not None and stamp - pending_stamp > window_ms:
|
|
240
|
+
self._verify(token)
|
|
241
|
+
|
|
242
|
+
def _verify(self, token: str) -> bool:
|
|
243
|
+
"""Check the token's book against the venue's last top of book. On a
|
|
244
|
+
mismatch, report the gap, mark the book not ready and resubscribe."""
|
|
245
|
+
pending = self._unverified.pop(token, None)
|
|
246
|
+
book = self.books.get(token)
|
|
247
|
+
if pending is None or book is None or not book.ready:
|
|
248
|
+
return True
|
|
249
|
+
_, best_bid, best_ask = pending
|
|
250
|
+
if _agrees(book.best_bid, best_bid) and _agrees(book.best_ask, best_ask):
|
|
251
|
+
return True
|
|
252
|
+
self.status(
|
|
253
|
+
"gap", f"book {token}: local top {book.best_bid}/{book.best_ask}, venue {best_bid}/{best_ask}", key=token,
|
|
254
|
+
)
|
|
255
|
+
book.invalidate()
|
|
256
|
+
self._later(self._resubscribe(token))
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
async def _resubscribe(self, token: str) -> None:
|
|
260
|
+
await self.send({"assets_ids": [token], "operation": "unsubscribe"})
|
|
261
|
+
await self.send({"assets_ids": [token], "operation": "subscribe"})
|
|
262
|
+
|
|
263
|
+
def _later(self, coroutine: Any) -> None:
|
|
264
|
+
import asyncio
|
|
265
|
+
|
|
266
|
+
asyncio.get_running_loop().create_task(coroutine)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class PolymarketUserStream(Stream):
|
|
270
|
+
"""This account's orders and trades, with settlement state.
|
|
271
|
+
|
|
272
|
+
Takes the CLOB API credentials directly, or a `PolymarketTrading`
|
|
273
|
+
adapter to derive them from.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
venue = VENUE
|
|
277
|
+
name = "user"
|
|
278
|
+
app_ping = "PING"
|
|
279
|
+
data_heartbeat = True
|
|
280
|
+
private = True
|
|
281
|
+
has: dict[str, Capability] = {
|
|
282
|
+
"watch_orders": True,
|
|
283
|
+
"watch_my_trades": True,
|
|
284
|
+
"watch_positions": False,
|
|
285
|
+
"watch_balance": False,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self, *, api_key: str | None = None, api_secret: str | None = None, api_passphrase: str | None = None,
|
|
290
|
+
trading: Any = None, wallets: set[str] | None = None, url: str = USER_URL, account_name: str = "default",
|
|
291
|
+
**kwargs: Any,
|
|
292
|
+
):
|
|
293
|
+
super().__init__(url, **kwargs)
|
|
294
|
+
self.trading = trading
|
|
295
|
+
self.api_key, self.api_secret, self.api_passphrase = api_key, api_secret, api_passphrase
|
|
296
|
+
self.wallets = set(wallets or ())
|
|
297
|
+
if trading is not None:
|
|
298
|
+
self.wallets |= {trading.wallet, trading.signer.address}
|
|
299
|
+
self.markets: set[str] | None = None
|
|
300
|
+
self.account = Account(venue=VENUE, name=account_name)
|
|
301
|
+
self.catalog = TokenCatalog(kwargs.get("gamma_url") or GAMMA_URL)
|
|
302
|
+
|
|
303
|
+
async def watch_orders(self, market_ids: list[str] | None = None) -> None:
|
|
304
|
+
"""Orders and trades for these markets, or for every market."""
|
|
305
|
+
if market_ids is None:
|
|
306
|
+
self.markets = None
|
|
307
|
+
else:
|
|
308
|
+
markets = [(await self.catalog.resolve(m)).condition_id for m in market_ids]
|
|
309
|
+
new = set(markets) - (self.markets or set())
|
|
310
|
+
self.markets = (self.markets or set()) | set(markets)
|
|
311
|
+
if new:
|
|
312
|
+
await self.send({"operation": "subscribe", "markets": sorted(new)})
|
|
313
|
+
return
|
|
314
|
+
if self._ws is not None:
|
|
315
|
+
await self.on_connect()
|
|
316
|
+
|
|
317
|
+
watch_my_trades = watch_orders
|
|
318
|
+
|
|
319
|
+
async def on_connect(self) -> None:
|
|
320
|
+
if self.trading is not None and not self.api_key:
|
|
321
|
+
await self.trading.ensure_api_credentials()
|
|
322
|
+
self.api_key, self.api_secret, self.api_passphrase = (
|
|
323
|
+
self.trading._api_key, self.trading._api_secret, self.trading._api_passphrase,
|
|
324
|
+
)
|
|
325
|
+
frame: dict[str, Any] = {
|
|
326
|
+
"auth": {"apiKey": self.api_key, "secret": self.api_secret, "passphrase": self.api_passphrase},
|
|
327
|
+
"type": "user",
|
|
328
|
+
}
|
|
329
|
+
if self.markets:
|
|
330
|
+
frame["markets"] = sorted(self.markets)
|
|
331
|
+
await self.send(frame)
|
|
332
|
+
|
|
333
|
+
def handle(self, message: Any) -> list[Event]:
|
|
334
|
+
if isinstance(message, list):
|
|
335
|
+
events: list[Event] = []
|
|
336
|
+
for item in message:
|
|
337
|
+
events.extend(self.handle(item))
|
|
338
|
+
return events
|
|
339
|
+
if not isinstance(message, dict):
|
|
340
|
+
return []
|
|
341
|
+
kind = message.get("event_type")
|
|
342
|
+
if kind == "order":
|
|
343
|
+
native = str(message.get("type") or "")
|
|
344
|
+
market_id, side = self._where(message)
|
|
345
|
+
order = order_of(message, market_id=market_id, outcome=side, account=self.account)
|
|
346
|
+
if native == "CANCELLATION" and not order.is_terminal:
|
|
347
|
+
order = order.model_copy(update={"status": OrderStatus.CANCELED, "remaining": Decimal("0")})
|
|
348
|
+
return [OrderEvent(venue=VENUE, order=order, native=native)]
|
|
349
|
+
if kind == "trade":
|
|
350
|
+
market_id, _ = self._where(message)
|
|
351
|
+
return [FillEvent(venue=VENUE, fill=fill) for fill in fills_of(
|
|
352
|
+
message, api_key=self.api_key or "", wallets=self.wallets, market_id=market_id,
|
|
353
|
+
outcome_of=self._outcome_of, account=self.account,
|
|
354
|
+
)]
|
|
355
|
+
return []
|
|
356
|
+
|
|
357
|
+
def _where(self, message: dict[str, Any]) -> tuple[str, str]:
|
|
358
|
+
"""`(market_id, side)` for an order or trade message. The user channel
|
|
359
|
+
names the outcome in words, so a market never seen by the catalog is
|
|
360
|
+
still placed on the right side."""
|
|
361
|
+
token = str(message.get("asset_id") or "")
|
|
362
|
+
tokens = self.catalog.cached(token) or self.catalog.cached(str(message.get("market") or ""))
|
|
363
|
+
if tokens is not None:
|
|
364
|
+
return tokens.market_id, tokens.outcome_of(token) if token in (tokens.yes_token, tokens.no_token) else _outcome_word(message)
|
|
365
|
+
return ids.qualify(VENUE, str(message.get("market") or "")), _outcome_word(message)
|
|
366
|
+
|
|
367
|
+
def _outcome_of(self, token: str) -> str:
|
|
368
|
+
tokens = self.catalog.cached(token)
|
|
369
|
+
return tokens.outcome_of(token) if tokens is not None else "yes"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _outcome_word(message: dict[str, Any]) -> str:
|
|
373
|
+
"""The venue's `outcome` label, when it is a plain Yes/No; the first
|
|
374
|
+
outcome otherwise."""
|
|
375
|
+
return "no" if str(message.get("outcome") or "").strip().lower() == "no" else "yes"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class TokenCatalog:
|
|
379
|
+
"""Which token is which side of which market, read from Gamma once per
|
|
380
|
+
market and kept by Gamma id, condition id and token id."""
|
|
381
|
+
|
|
382
|
+
def __init__(self, gamma_url: str = GAMMA_URL):
|
|
383
|
+
self.gamma_url = gamma_url.rstrip("/")
|
|
384
|
+
self._by_key: dict[str, MarketTokens] = {}
|
|
385
|
+
|
|
386
|
+
def remember(self, tokens: MarketTokens) -> MarketTokens:
|
|
387
|
+
for key in (tokens.gamma_id, tokens.condition_id, tokens.yes_token, tokens.no_token):
|
|
388
|
+
if key:
|
|
389
|
+
self._by_key[key] = tokens
|
|
390
|
+
return tokens
|
|
391
|
+
|
|
392
|
+
def cached(self, key: str) -> MarketTokens | None:
|
|
393
|
+
"""By Synpath id, Gamma id, condition id or token id, if seen."""
|
|
394
|
+
_, native = ids.split(key)
|
|
395
|
+
return self._by_key.get(native)
|
|
396
|
+
|
|
397
|
+
async def resolve(self, market_id: str) -> MarketTokens:
|
|
398
|
+
native = ids.native(VENUE, market_id)
|
|
399
|
+
cached = self._by_key.get(native)
|
|
400
|
+
if cached is not None:
|
|
401
|
+
return cached
|
|
402
|
+
async with httpx.AsyncClient(base_url=self.gamma_url, timeout=15) as client:
|
|
403
|
+
if native.startswith("0x"):
|
|
404
|
+
response = await client.get("/markets", params={"condition_ids": native})
|
|
405
|
+
rows = response.json() if response.status_code == 200 else []
|
|
406
|
+
raw = rows[0] if isinstance(rows, list) and rows else None
|
|
407
|
+
else:
|
|
408
|
+
response = await client.get(f"/markets/{native}")
|
|
409
|
+
raw = response.json() if response.status_code == 200 else None
|
|
410
|
+
if not isinstance(raw, dict) or not raw.get("id"):
|
|
411
|
+
from ..errors import MarketNotFound
|
|
412
|
+
raise MarketNotFound(f"polymarket: no market {native}")
|
|
413
|
+
return self.remember(MarketTokens.from_gamma(raw))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _ms(value: Any) -> int | None:
|
|
417
|
+
if value in (None, ""):
|
|
418
|
+
return None
|
|
419
|
+
number = int(str(value))
|
|
420
|
+
return number if number > 1e11 else number * 1000
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _agrees(local: Decimal | None, venue: Any) -> bool:
|
|
424
|
+
"""A venue top of book of `0`, `1` or empty means no level on that side."""
|
|
425
|
+
if venue in (None, ""):
|
|
426
|
+
return True
|
|
427
|
+
price = Decimal(str(venue))
|
|
428
|
+
if local is None:
|
|
429
|
+
return price in (Decimal("0"), Decimal("1"))
|
|
430
|
+
return local == price
|