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,299 @@
|
|
|
1
|
+
"""Polymarket US's retail WebSockets: markets and private.
|
|
2
|
+
|
|
3
|
+
Both live at `wss://api.polymarket.us/v1/ws/...` and authenticate the
|
|
4
|
+
handshake with the retail API key, signing `timestamp + GET + path` as REST
|
|
5
|
+
does. Subscriptions take at most 100 markets each; the stream splits larger
|
|
6
|
+
sets. The server sends `{"heartbeat": {}}` periodically, so a silent
|
|
7
|
+
connection is a dead one.
|
|
8
|
+
|
|
9
|
+
**Markets.** Full market data arrives as the whole visible book each time,
|
|
10
|
+
not as deltas, so there is nothing to fall out of step with: every message
|
|
11
|
+
is a snapshot for `{slug}:yes`. A change of market state (open, suspended,
|
|
12
|
+
halted, expired) is reported when it is first seen. Trades carry the maker
|
|
13
|
+
and taker sides but no sequence number.
|
|
14
|
+
|
|
15
|
+
**Private.** Orders (a snapshot of open orders, then executions), positions
|
|
16
|
+
and balances. Nothing is replayed after a reconnect; the order subscription
|
|
17
|
+
sends a fresh snapshot of open orders when it is made again, but fills and
|
|
18
|
+
position changes missed in between are gone, hence `reconcile_required`.
|
|
19
|
+
|
|
20
|
+
The documentation spells the envelopes two ways (`orderSubscriptionSnapshot`
|
|
21
|
+
and `ordersSnapshot`, and so on) and the venue's own SDK accepts both; so
|
|
22
|
+
does this stream.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from decimal import Decimal
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from .. import ids
|
|
30
|
+
from ..base import Capability
|
|
31
|
+
from ..polymarket_us import parse_ts
|
|
32
|
+
from ..trading.credentials import PolymarketUSCredentials
|
|
33
|
+
from ..trading.polymarket_us import (
|
|
34
|
+
PolymarketUSSigner, balance_of, order_of, position_of,
|
|
35
|
+
)
|
|
36
|
+
from ..trading.types import Account, Fill, Liquidity, Side
|
|
37
|
+
from .base import (
|
|
38
|
+
BookEvent, D, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, Stream,
|
|
39
|
+
TradeEvent, BalanceEvent, maybe_D,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
VENUE = "polymarket_us"
|
|
43
|
+
BASE_URL = "wss://api.polymarket.us"
|
|
44
|
+
MARKETS_PATH = "/v1/ws/markets"
|
|
45
|
+
PRIVATE_PATH = "/v1/ws/private"
|
|
46
|
+
MAX_SLUGS = 100
|
|
47
|
+
|
|
48
|
+
MARKET_STATE = {
|
|
49
|
+
"MARKET_STATE_OPEN": "open",
|
|
50
|
+
"MARKET_STATE_PREOPEN": "paused",
|
|
51
|
+
"MARKET_STATE_SUSPENDED": "paused",
|
|
52
|
+
"MARKET_STATE_HALTED": "paused",
|
|
53
|
+
"MARKET_STATE_MATCH_AND_CLOSE_AUCTION": "closed",
|
|
54
|
+
"MARKET_STATE_EXPIRED": "closed",
|
|
55
|
+
"MARKET_STATE_TERMINATED": "settled",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _first(message: dict[str, Any], *names: str) -> Any:
|
|
60
|
+
for name in names:
|
|
61
|
+
if name in message:
|
|
62
|
+
return message[name]
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _SignedStream(Stream):
|
|
67
|
+
path = ""
|
|
68
|
+
data_heartbeat = True
|
|
69
|
+
|
|
70
|
+
def __init__(self, credentials: PolymarketUSCredentials, *, base_url: str = BASE_URL, account_name: str = "default", **kwargs: Any):
|
|
71
|
+
super().__init__(base_url + self.path, **kwargs)
|
|
72
|
+
self.signer = PolymarketUSSigner(credentials.key_id, credentials.secret_key)
|
|
73
|
+
self.account = Account(venue=VENUE, name=account_name)
|
|
74
|
+
self._subscriptions: dict[str, dict[str, Any]] = {}
|
|
75
|
+
self._next = 1
|
|
76
|
+
|
|
77
|
+
def headers(self) -> dict[str, str]:
|
|
78
|
+
return self.signer.headers("GET", self.path)
|
|
79
|
+
|
|
80
|
+
async def _subscribe(self, kind: str, slugs: list[str] | None) -> None:
|
|
81
|
+
chunks = [slugs[i:i + MAX_SLUGS] for i in range(0, len(slugs), MAX_SLUGS)] if slugs else [None]
|
|
82
|
+
for chunk in chunks:
|
|
83
|
+
request_id = f"{kind.lower().removeprefix('subscription_type_')}-{self._next}"
|
|
84
|
+
self._next += 1
|
|
85
|
+
body: dict[str, Any] = {"requestId": request_id, "subscriptionType": kind}
|
|
86
|
+
if chunk:
|
|
87
|
+
body["marketSlugs"] = chunk
|
|
88
|
+
self._subscriptions[request_id] = body
|
|
89
|
+
await self.send({"subscribe": body})
|
|
90
|
+
|
|
91
|
+
async def on_connect(self) -> None:
|
|
92
|
+
for body in self._subscriptions.values():
|
|
93
|
+
await self.send({"subscribe": body})
|
|
94
|
+
|
|
95
|
+
def _common(self, message: dict[str, Any]) -> list[Event] | None:
|
|
96
|
+
if "heartbeat" in message:
|
|
97
|
+
return []
|
|
98
|
+
if "error" in message:
|
|
99
|
+
self.status("error", str(message.get("error")), key=message.get("requestId") or message.get("request_id"))
|
|
100
|
+
return []
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PolymarketUSMarketStream(_SignedStream):
|
|
105
|
+
"""Books, top of book and trades for Polymarket US markets."""
|
|
106
|
+
|
|
107
|
+
venue = VENUE
|
|
108
|
+
name = "markets"
|
|
109
|
+
path = MARKETS_PATH
|
|
110
|
+
has: dict[str, Capability] = {
|
|
111
|
+
"watch_order_book": True,
|
|
112
|
+
"watch_ticker": True,
|
|
113
|
+
"watch_trades": True,
|
|
114
|
+
# Market state rides on full market data; there is no lifecycle
|
|
115
|
+
# channel for markets not subscribed.
|
|
116
|
+
"watch_market_status": "partial",
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def __init__(self, credentials: PolymarketUSCredentials, **kwargs: Any):
|
|
120
|
+
super().__init__(credentials, **kwargs)
|
|
121
|
+
self.books: dict[str, LocalBook] = {}
|
|
122
|
+
self.states: dict[str, str] = {}
|
|
123
|
+
|
|
124
|
+
async def watch_order_book(self, market_ids: list[str], *, debounced: bool = False) -> None:
|
|
125
|
+
slugs = _slugs(market_ids)
|
|
126
|
+
"""Full visible book and market state for these markets."""
|
|
127
|
+
for slug in slugs:
|
|
128
|
+
self.books.setdefault(slug, LocalBook())
|
|
129
|
+
await self._subscribe("SUBSCRIPTION_TYPE_MARKET_DATA", slugs)
|
|
130
|
+
if debounced:
|
|
131
|
+
for body in list(self._subscriptions.values())[-((len(slugs) - 1) // MAX_SLUGS + 1):]:
|
|
132
|
+
body["responsesDebounced"] = True
|
|
133
|
+
|
|
134
|
+
async def watch_ticker(self, market_ids: list[str]) -> None:
|
|
135
|
+
await self._subscribe("SUBSCRIPTION_TYPE_MARKET_DATA_LITE", _slugs(market_ids))
|
|
136
|
+
|
|
137
|
+
async def watch_trades(self, market_ids: list[str]) -> None:
|
|
138
|
+
await self._subscribe("SUBSCRIPTION_TYPE_TRADE", _slugs(market_ids))
|
|
139
|
+
|
|
140
|
+
def on_disconnect(self) -> None:
|
|
141
|
+
for book in self.books.values():
|
|
142
|
+
book.invalidate()
|
|
143
|
+
|
|
144
|
+
def book(self, market_id: str, side: str = "yes") -> LocalBook | None:
|
|
145
|
+
book = self.books.get(ids.native(VENUE, market_id))
|
|
146
|
+
return None if book is None else book if side != "no" else book.mirrored()
|
|
147
|
+
|
|
148
|
+
def handle(self, message: Any) -> list[Event]:
|
|
149
|
+
if not isinstance(message, dict):
|
|
150
|
+
return []
|
|
151
|
+
common = self._common(message)
|
|
152
|
+
if common is not None:
|
|
153
|
+
return common
|
|
154
|
+
data = _first(message, "marketData", "market_data")
|
|
155
|
+
if data is not None:
|
|
156
|
+
return self._market_data(data)
|
|
157
|
+
lite = _first(message, "marketDataLite", "market_data_lite")
|
|
158
|
+
if lite is not None:
|
|
159
|
+
slug = str(lite.get("marketSlug") or "")
|
|
160
|
+
return [QuoteEvent(
|
|
161
|
+
venue=VENUE, market_id=ids.qualify(VENUE, slug),
|
|
162
|
+
bid=maybe_D(lite.get("bestBid")), ask=maybe_D(lite.get("bestAsk")),
|
|
163
|
+
last=maybe_D(lite.get("lastTradePx")), volume=maybe_D(lite.get("sharesTraded")),
|
|
164
|
+
open_interest=maybe_D(lite.get("openInterest")), info=lite,
|
|
165
|
+
)]
|
|
166
|
+
trade = message.get("trade")
|
|
167
|
+
if trade is not None:
|
|
168
|
+
slug = str(trade.get("marketSlug") or "")
|
|
169
|
+
taker = (trade.get("taker") or {}).get("side")
|
|
170
|
+
return [TradeEvent(
|
|
171
|
+
venue=VENUE, market_id=ids.qualify(VENUE, slug), id=trade.get("tradeId") or trade.get("id"),
|
|
172
|
+
price=D(trade["price"]), amount=D(trade.get("quantity") or trade.get("qty")),
|
|
173
|
+
taker_side=Side.BUY if taker == "ORDER_SIDE_BUY" else Side.SELL if taker == "ORDER_SIDE_SELL" else None,
|
|
174
|
+
timestamp=parse_ts(trade.get("tradeTime")), info=trade,
|
|
175
|
+
)]
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
def _market_data(self, data: dict[str, Any]) -> list[Event]:
|
|
179
|
+
slug = str(data.get("marketSlug") or "")
|
|
180
|
+
book = self.books.setdefault(slug, LocalBook())
|
|
181
|
+
book.replace(
|
|
182
|
+
[(D(level["px"]), D(level["qty"])) for level in data.get("bids") or []],
|
|
183
|
+
[(D(level["px"]), D(level["qty"])) for level in data.get("offers") or []],
|
|
184
|
+
)
|
|
185
|
+
stamp = parse_ts(data.get("transactTime"))
|
|
186
|
+
book.timestamp = stamp
|
|
187
|
+
bids, asks = book.levels()
|
|
188
|
+
events: list[Event] = [BookEvent(
|
|
189
|
+
venue=VENUE, market_id=ids.qualify(VENUE, slug), kind="snapshot", bids=bids, asks=asks,
|
|
190
|
+
best_bid=book.best_bid, best_ask=book.best_ask, timestamp=stamp,
|
|
191
|
+
info={"state": data.get("state"), "stats": data.get("stats")},
|
|
192
|
+
)]
|
|
193
|
+
native = data.get("state")
|
|
194
|
+
if native and self.states.get(slug) != native:
|
|
195
|
+
self.states[slug] = native
|
|
196
|
+
events.append(MarketStatusEvent(
|
|
197
|
+
venue=VENUE, market_id=ids.qualify(VENUE, slug), state=MARKET_STATE.get(native, "updated"), # type: ignore[arg-type]
|
|
198
|
+
native=native, timestamp=stamp, info={"state": native},
|
|
199
|
+
))
|
|
200
|
+
return events
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class PolymarketUSPrivateStream(_SignedStream):
|
|
204
|
+
"""This account's orders, fills, positions and balance."""
|
|
205
|
+
|
|
206
|
+
venue = VENUE
|
|
207
|
+
name = "private"
|
|
208
|
+
path = PRIVATE_PATH
|
|
209
|
+
private = True
|
|
210
|
+
has: dict[str, Capability] = {
|
|
211
|
+
"watch_orders": True,
|
|
212
|
+
"watch_my_trades": True,
|
|
213
|
+
"watch_positions": True,
|
|
214
|
+
"watch_balance": True,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async def watch_orders(self, market_ids: list[str] | None = None) -> None:
|
|
218
|
+
slugs = _slugs(market_ids)
|
|
219
|
+
"""Open orders as a snapshot, then every execution: acceptance, fills,
|
|
220
|
+
cancels, replaces, rejects. Fills arrive as `FillEvent`s too."""
|
|
221
|
+
await self._subscribe("SUBSCRIPTION_TYPE_ORDER", slugs)
|
|
222
|
+
|
|
223
|
+
watch_my_trades = watch_orders
|
|
224
|
+
|
|
225
|
+
async def watch_positions(self, market_ids: list[str] | None = None) -> None:
|
|
226
|
+
await self._subscribe("SUBSCRIPTION_TYPE_POSITION", _slugs(market_ids))
|
|
227
|
+
|
|
228
|
+
async def watch_balance(self) -> None:
|
|
229
|
+
await self._subscribe("SUBSCRIPTION_TYPE_ACCOUNT_BALANCE", None)
|
|
230
|
+
|
|
231
|
+
def handle(self, message: Any) -> list[Event]:
|
|
232
|
+
if not isinstance(message, dict):
|
|
233
|
+
return []
|
|
234
|
+
common = self._common(message)
|
|
235
|
+
if common is not None:
|
|
236
|
+
return common
|
|
237
|
+
snapshot = _first(message, "orderSubscriptionSnapshot", "ordersSnapshot")
|
|
238
|
+
if snapshot is not None:
|
|
239
|
+
return [OrderEvent(venue=VENUE, order=order_of(row, account=self.account), native="snapshot") for row in snapshot.get("orders") or []]
|
|
240
|
+
update = _first(message, "orderSubscriptionUpdate", "orderUpdate")
|
|
241
|
+
if update is not None:
|
|
242
|
+
return self._execution(update.get("execution") or update)
|
|
243
|
+
position = _first(message, "positionSubscription", "positionSubscriptionUpdate", "positionUpdate")
|
|
244
|
+
if position is not None:
|
|
245
|
+
return self._position(position)
|
|
246
|
+
positions = _first(message, "positionSubscriptionSnapshot", "positionsSnapshot")
|
|
247
|
+
if positions is not None:
|
|
248
|
+
rows = positions.get("positions") or {}
|
|
249
|
+
return [PositionEvent(venue=VENUE, position=position_of(slug, row, account=self.account)) for slug, row in rows.items()]
|
|
250
|
+
balances = _first(
|
|
251
|
+
message, "accountBalancesSnapshot", "accountBalanceSubscriptionSnapshot",
|
|
252
|
+
"accountBalancesUpdate", "accountBalanceSubscriptionUpdate", "accountBalanceUpdate",
|
|
253
|
+
)
|
|
254
|
+
if balances is not None:
|
|
255
|
+
return self._balance(balances)
|
|
256
|
+
return []
|
|
257
|
+
|
|
258
|
+
def _execution(self, execution: dict[str, Any]) -> list[Event]:
|
|
259
|
+
raw_order = execution.get("order") or {}
|
|
260
|
+
order = order_of(raw_order, account=self.account)
|
|
261
|
+
kind = str(execution.get("type") or "")
|
|
262
|
+
events: list[Event] = [OrderEvent(venue=VENUE, order=order, native=kind)]
|
|
263
|
+
if kind in ("EXECUTION_TYPE_PARTIAL_FILL", "EXECUTION_TYPE_FILL") and execution.get("lastShares"):
|
|
264
|
+
wire_price = maybe_D(execution.get("lastPx"))
|
|
265
|
+
aggressor = execution.get("aggressor")
|
|
266
|
+
events.append(FillEvent(venue=VENUE, fill=Fill(
|
|
267
|
+
id=str(execution.get("tradeId") or execution.get("id") or ""), order_id=order.id, venue=VENUE,
|
|
268
|
+
account=self.account, market_id=order.market_id, side=order.side,
|
|
269
|
+
price=wire_price if wire_price is not None else Decimal("0"),
|
|
270
|
+
amount=D(execution["lastShares"]),
|
|
271
|
+
fee=maybe_D(execution.get("commissionNotionalCollected")), fee_currency="USD",
|
|
272
|
+
liquidity=Liquidity.TAKER if aggressor is True else Liquidity.MAKER if aggressor is False else Liquidity.UNKNOWN,
|
|
273
|
+
timestamp=parse_ts(execution.get("transactTime")) or 0, info=execution,
|
|
274
|
+
)))
|
|
275
|
+
return events
|
|
276
|
+
|
|
277
|
+
def _position(self, body: dict[str, Any]) -> list[Event]:
|
|
278
|
+
row = body.get("afterPosition") or body.get("position") or {}
|
|
279
|
+
slug = str(body.get("marketSlug") or (row.get("marketMetadata") or {}).get("slug") or "")
|
|
280
|
+
position = position_of(slug, row, account=self.account)
|
|
281
|
+
stamp = parse_ts(body.get("updateTime"))
|
|
282
|
+
return [PositionEvent(venue=VENUE, position=position.model_copy(update={
|
|
283
|
+
"timestamp": stamp or position.timestamp, "info": body,
|
|
284
|
+
}))]
|
|
285
|
+
|
|
286
|
+
def _balance(self, body: dict[str, Any]) -> list[Event]:
|
|
287
|
+
if "balances" in body:
|
|
288
|
+
return [BalanceEvent(venue=VENUE, balance=balance_of(body, account=self.account))]
|
|
289
|
+
after = (body.get("balanceChange") or {}).get("afterBalance")
|
|
290
|
+
if after is not None:
|
|
291
|
+
return [BalanceEvent(venue=VENUE, balance=balance_of({"balances": [after]}, account=self.account))]
|
|
292
|
+
if "balance" in body or "buyingPower" in body:
|
|
293
|
+
row = {"currentBalance": body.get("balance"), "buyingPower": body.get("buyingPower"), "currency": "USD"}
|
|
294
|
+
return [BalanceEvent(venue=VENUE, balance=balance_of({"balances": [row]}, account=self.account))]
|
|
295
|
+
return []
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _slugs(market_ids: list[str] | None) -> list[str] | None:
|
|
299
|
+
return None if market_ids is None else [ids.native(VENUE, m) for m in market_ids]
|