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,754 @@
|
|
|
1
|
+
"""Polymarket US exchange API streams, over gRPC.
|
|
2
|
+
|
|
3
|
+
`grpc-api.{preprod,prod}.polymarketexchange.com:443`, authenticated with the
|
|
4
|
+
same Auth0 access token as the exchange REST API (sent as `authorization:
|
|
5
|
+
Bearer` metadata, with `x-participant-id` on account data). Each stream
|
|
6
|
+
here is one server-streaming RPC:
|
|
7
|
+
|
|
8
|
+
| Stream | RPC | Carries |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| `PolymarketUSExchangeOrderStream` | `OrderEntryAPI.CreateOrderSubscription` | open orders as a snapshot, then every execution |
|
|
11
|
+
| `PolymarketUSExchangeDropCopyStream` | `DropCopyAPI.CreateDropCopySubscription` | every execution in the firm, resumable |
|
|
12
|
+
| `PolymarketUSExchangeTradeCaptureStream` | `DropCopyAPI.CreateTradeCaptureReportSubscription` | trades through clearing, busts included, resumable |
|
|
13
|
+
| `PolymarketUSExchangePositionChangeStream` | `DropCopyAPI.CreatePositionChangeSubscription` | position changes, resumable |
|
|
14
|
+
| `PolymarketUSExchangeInstrumentStream` | `DropCopyAPI.CreateInstrumentStateChangeSubscription` | instrument state (open, halted, expired ...), resumable |
|
|
15
|
+
| `PolymarketUSExchangePositionStream` | `PositionAPI.CreatePositionSubscription` | positions as a snapshot, then changes |
|
|
16
|
+
| `PolymarketUSExchangeMarketDataStream` | `MarketDataSubscriptionAPI.CreateMarketDataSubscription` | books to a depth, statistics |
|
|
17
|
+
| `PolymarketUSExchangeBalanceLedgerStream` | `FundingAPI.CreateBalanceLedgerSubscription` | balance ledger entries, replayable by time |
|
|
18
|
+
|
|
19
|
+
What shapes them:
|
|
20
|
+
|
|
21
|
+
**The venue's protos are needed and not shipped.** See `synpath.ws.grpc`.
|
|
22
|
+
|
|
23
|
+
**Delivery is at least once.** Executions are deduplicated by id; trade
|
|
24
|
+
reports by id *and state*, because a trade is sent again under the same id
|
|
25
|
+
when it clears or is busted.
|
|
26
|
+
|
|
27
|
+
**Drop copy resumes.** Every drop-copy response carries a resume token. A
|
|
28
|
+
reconnect sends the last one, so nothing is missed and no reconciliation is
|
|
29
|
+
asked for; a token the venue refuses falls back to resuming from the time of
|
|
30
|
+
the last event. After each batch that moves the token a
|
|
31
|
+
`VenueEvent(name="checkpoint")` carries it: persist it once the events
|
|
32
|
+
before it are handled, and pass it back as `resume_token=` after a restart.
|
|
33
|
+
|
|
34
|
+
**A busted trade is a failed fill.** Trade capture reports become `FillEvent`s
|
|
35
|
+
whose `settlement` is `matched` while the trade is on its way through
|
|
36
|
+
clearing, `confirmed` once cleared, and `failed` when it is busted or the
|
|
37
|
+
clearing house rejects it -- the same fill id each time, as on Polymarket.
|
|
38
|
+
|
|
39
|
+
**Integers everywhere.** Orders carry the scales they were entered with, so
|
|
40
|
+
order and execution streams decode themselves. Positions and books carry
|
|
41
|
+
none; their scales come from reference data (six requests a minute, firm
|
|
42
|
+
wide), read once per symbol and cached on the trading adapter.
|
|
43
|
+
|
|
44
|
+
**Twenty streams a firm.** Every stream here is one of them.
|
|
45
|
+
|
|
46
|
+
Built from the published protos and documentation. A market-data update is
|
|
47
|
+
read as the whole book to the requested depth, since the message carries no
|
|
48
|
+
way to mark a level removed, and heartbeat intervals are not published, so
|
|
49
|
+
heartbeat streams reconnect after two minutes of silence.
|
|
50
|
+
"""
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import base64
|
|
54
|
+
from decimal import Decimal
|
|
55
|
+
from typing import Any, Callable
|
|
56
|
+
|
|
57
|
+
from .. import ids
|
|
58
|
+
from ..base import Capability
|
|
59
|
+
from ..errors import ExchangeError
|
|
60
|
+
from ..polymarket_us import parse_ts
|
|
61
|
+
from ..trading.credentials import PolymarketUSExchangeCredentials
|
|
62
|
+
from ..trading.polymarket_us_exchange import (
|
|
63
|
+
InstrumentScale, PolymarketUSExchangeTrading, fill_of, order_of, position_of, rfc3339, scale_from_order,
|
|
64
|
+
)
|
|
65
|
+
from ..trading.types import Account, Fill, Position, PositionSide, SettlementState
|
|
66
|
+
from .base import (
|
|
67
|
+
BookEvent, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, VenueEvent, now_ms,
|
|
68
|
+
)
|
|
69
|
+
from .grpc import GrpcStream, Outcome, ProtoBundle, RecentIds
|
|
70
|
+
|
|
71
|
+
VENUE = "polymarket_us"
|
|
72
|
+
TARGETS = {
|
|
73
|
+
"preprod": "grpc-api.preprod.polymarketexchange.com:443",
|
|
74
|
+
"prod": "grpc-api.prod.polymarketexchange.com:443",
|
|
75
|
+
}
|
|
76
|
+
ANCHOR = "polymarket/v1/trading.proto"
|
|
77
|
+
PROTO_FILES = (
|
|
78
|
+
"polymarket/v1/trading.proto",
|
|
79
|
+
"polymarket/v1/dropcopy.proto",
|
|
80
|
+
"polymarket/v1/positions.proto",
|
|
81
|
+
"polymarket/v1/marketdatasubscription.proto",
|
|
82
|
+
"polymarket/v1/funding.proto",
|
|
83
|
+
)
|
|
84
|
+
MAX_SYMBOLS = 1000
|
|
85
|
+
HEARTBEAT_IDLE_S = 120.0
|
|
86
|
+
|
|
87
|
+
FILL_TYPES = {"EXECUTION_TYPE_FILL", "EXECUTION_TYPE_PARTIAL_FILL"}
|
|
88
|
+
|
|
89
|
+
INSTRUMENT_STATE = {
|
|
90
|
+
"INSTRUMENT_STATE_PENDING": "created",
|
|
91
|
+
"INSTRUMENT_STATE_OPEN": "open",
|
|
92
|
+
"INSTRUMENT_STATE_PREOPEN": "paused",
|
|
93
|
+
"INSTRUMENT_STATE_SUSPENDED": "paused",
|
|
94
|
+
"INSTRUMENT_STATE_HALTED": "paused",
|
|
95
|
+
"INSTRUMENT_STATE_MATCH_AND_CLOSE_AUCTION": "closed",
|
|
96
|
+
"INSTRUMENT_STATE_CLOSED": "closed",
|
|
97
|
+
"INSTRUMENT_STATE_EXPIRED": "closed",
|
|
98
|
+
"INSTRUMENT_STATE_TERMINATED": "settled",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
TRADE_SETTLEMENT = {
|
|
102
|
+
"TRADE_STATE_CLEARED": SettlementState.CONFIRMED,
|
|
103
|
+
"TRADE_STATE_BUSTED": SettlementState.FAILED,
|
|
104
|
+
"TRADE_STATE_REJECTED": SettlementState.FAILED,
|
|
105
|
+
}
|
|
106
|
+
"""Every other state (new, in flight, pending, acknowledged, retrying, and any
|
|
107
|
+
state added later) is a trade still on its way: `matched`."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_protos(path: Any = None, **kwargs: Any) -> ProtoBundle:
|
|
111
|
+
"""The exchange API's protos from `path` (a directory or the zip), or from
|
|
112
|
+
`SYNPATH_POLYMARKET_US_PROTOS`."""
|
|
113
|
+
return ProtoBundle.load(path, anchor=ANCHOR, files=PROTO_FILES, **kwargs)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Pure normalizers, on messages as dicts
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _enum_word(value: Any, prefix: str) -> str:
|
|
121
|
+
text = str(value or "")
|
|
122
|
+
return text[len(prefix):].lower() if text.startswith(prefix) else text.lower()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _carries_scale(order: dict[str, Any]) -> bool:
|
|
126
|
+
"""Whether an order carries its scales; unset int64s arrive as "0"."""
|
|
127
|
+
return int(order.get("priceScale") or 0) != 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _require(scale: InstrumentScale | None, symbol: str) -> InstrumentScale:
|
|
131
|
+
if scale is None:
|
|
132
|
+
raise LookupError(f"no reference data for {symbol!r}, so its integers cannot be read")
|
|
133
|
+
return scale
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def execution_events(execution: dict[str, Any], scale: InstrumentScale | None, *, account: Account | None) -> list[Event]:
|
|
137
|
+
"""An execution report as the order it leaves behind and, for a fill, the fill."""
|
|
138
|
+
raw_order = execution.get("order") or {}
|
|
139
|
+
symbol = str(raw_order.get("symbol") or "")
|
|
140
|
+
scale = _require(scale_from_order(raw_order, scale), symbol)
|
|
141
|
+
kind = str(execution.get("type") or "EXECUTION_TYPE_NEW")
|
|
142
|
+
order = order_of(raw_order, scale, account=account)
|
|
143
|
+
detail = {k: execution[k] for k in ("id", "type", "text", "transactTime") if execution.get(k) not in (None, "")}
|
|
144
|
+
# Both reasons default to a real-looking value; they mean something only
|
|
145
|
+
# on a reject or an unsolicited cancel.
|
|
146
|
+
if kind == "EXECUTION_TYPE_REJECTED" and execution.get("orderRejectReason"):
|
|
147
|
+
detail["orderRejectReason"] = execution["orderRejectReason"]
|
|
148
|
+
if str(execution.get("unsolicitedCancelReason") or "").removesuffix("_UNDEFINED") not in ("", "UNSOLICITED_CXL_REASON"):
|
|
149
|
+
detail["unsolicitedCancelReason"] = execution["unsolicitedCancelReason"]
|
|
150
|
+
order = order.model_copy(update={"info": {**order.info, "execution": detail}})
|
|
151
|
+
events: list[Event] = [OrderEvent(venue=VENUE, order=order, native=_enum_word(kind, "EXECUTION_TYPE_"))]
|
|
152
|
+
if kind in FILL_TYPES and scale.qty_from_wire(execution.get("lastShares")) != 0:
|
|
153
|
+
events.append(FillEvent(venue=VENUE, fill=fill_of(execution, scale, account=account)))
|
|
154
|
+
return events
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def trade_fills(
|
|
158
|
+
trade: dict[str, Any],
|
|
159
|
+
scale_for: Callable[[str], InstrumentScale | None],
|
|
160
|
+
*,
|
|
161
|
+
account_for: Callable[[str], Account | None],
|
|
162
|
+
) -> list[Fill]:
|
|
163
|
+
"""A trade capture report as this firm's fills, settled as far as clearing
|
|
164
|
+
has got. `reportingCounterparty` names the firm's side; where it is not
|
|
165
|
+
given, every execution that names an account is the firm's."""
|
|
166
|
+
state = str(trade.get("state") or "TRADE_STATE_UNDEFINED")
|
|
167
|
+
settlement = TRADE_SETTLEMENT.get(state, SettlementState.MATCHED)
|
|
168
|
+
reporting = str(trade.get("reportingCounterparty") or "")
|
|
169
|
+
fills: list[Fill] = []
|
|
170
|
+
for role in ("aggressor", "passive"):
|
|
171
|
+
execution = trade.get(role)
|
|
172
|
+
if not execution:
|
|
173
|
+
continue
|
|
174
|
+
raw_order = execution.get("order") or {}
|
|
175
|
+
if reporting in ("SIDE_BUY", "SIDE_SELL"):
|
|
176
|
+
if raw_order.get("side") != reporting:
|
|
177
|
+
continue
|
|
178
|
+
elif not raw_order.get("account"):
|
|
179
|
+
continue
|
|
180
|
+
symbol = str(raw_order.get("symbol") or "")
|
|
181
|
+
scale = _require(scale_from_order(raw_order, scale_for(symbol)), symbol)
|
|
182
|
+
fill = fill_of({**execution, "tradeId": execution.get("tradeId") or trade.get("id")}, scale, account=account_for(str(raw_order.get("account") or "")))
|
|
183
|
+
fills.append(fill.model_copy(update={
|
|
184
|
+
"settlement": settlement,
|
|
185
|
+
"info": {**fill.info, "trade": {k: v for k, v in trade.items() if k not in ("aggressor", "passive")}, "role": role},
|
|
186
|
+
}))
|
|
187
|
+
return fills
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def market_status_of(instrument: dict[str, Any]) -> MarketStatusEvent:
|
|
191
|
+
native = str(instrument.get("state") or "INSTRUMENT_STATE_CLOSED")
|
|
192
|
+
return MarketStatusEvent(
|
|
193
|
+
venue=VENUE, market_id=ids.qualify(VENUE, str(instrument.get("symbol") or "")), state=INSTRUMENT_STATE.get(native, "updated"), # type: ignore[arg-type]
|
|
194
|
+
native=native, timestamp=parse_ts(instrument.get("updateTime")),
|
|
195
|
+
info={k: instrument[k] for k in ("priceScale", "fractionalQtyScale", "nonTradable", "description") if k in instrument},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def flat_position(account: Account | None, symbol: str) -> Position:
|
|
200
|
+
return Position(venue=VENUE, account=account, market_id=ids.qualify(VENUE, symbol), side=PositionSide.FLAT)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def ledger_event(entry: dict[str, Any]) -> VenueEvent:
|
|
204
|
+
"""A balance ledger entry. Balances are decimal strings on this message."""
|
|
205
|
+
before = Decimal(str(entry.get("beforeBalance") or "0"))
|
|
206
|
+
after = Decimal(str(entry.get("afterBalance") or "0"))
|
|
207
|
+
kind = str(entry.get("entryType") or "")
|
|
208
|
+
word = _enum_word(kind, "LEDGER_ENTRY_TYPE_BALANCE_") if kind.startswith("LEDGER_ENTRY_TYPE_BALANCE_") else _enum_word(kind, "LEDGER_ENTRY_TYPE_")
|
|
209
|
+
stamp = parse_ts(entry.get("updateTime"))
|
|
210
|
+
return VenueEvent(venue=VENUE, name="balance_ledger", timestamp=stamp, payload={
|
|
211
|
+
"id": str(entry.get("id") or ""), "account": str(entry.get("account") or ""),
|
|
212
|
+
"currency": str(entry.get("currency") or ""), "before": before, "after": after, "change": after - before,
|
|
213
|
+
"entry_type": word, "symbol": entry.get("symbol") or None, "description": entry.get("description") or "",
|
|
214
|
+
"business_date": entry.get("updateBusinessDate") or None, "timestamp": stamp, "info": entry,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def book_sides(update: dict[str, Any], scale: InstrumentScale) -> tuple[list[tuple[Decimal, Decimal]], list[tuple[Decimal, Decimal]]]:
|
|
219
|
+
def side(rows: Any) -> list[tuple[Decimal, Decimal]]:
|
|
220
|
+
out = []
|
|
221
|
+
for row in rows or []:
|
|
222
|
+
price = scale.price_from_wire(row.get("px"))
|
|
223
|
+
if price is not None:
|
|
224
|
+
out.append((price, scale.qty_from_wire(row.get("qty"))))
|
|
225
|
+
return out
|
|
226
|
+
return side(update.get("bids")), side(update.get("offers"))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Streams
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
class _ExchangeStream(GrpcStream):
|
|
234
|
+
venue = VENUE
|
|
235
|
+
account_scoped = True
|
|
236
|
+
"""Sends `x-participant-id`; reference and market data need none."""
|
|
237
|
+
|
|
238
|
+
def __init__(
|
|
239
|
+
self,
|
|
240
|
+
source: PolymarketUSExchangeCredentials | PolymarketUSExchangeTrading,
|
|
241
|
+
*,
|
|
242
|
+
protos: ProtoBundle | Any = None,
|
|
243
|
+
target: str | None = None,
|
|
244
|
+
**kwargs: Any,
|
|
245
|
+
):
|
|
246
|
+
if isinstance(source, PolymarketUSExchangeTrading):
|
|
247
|
+
self.trading, self._owns_trading = source, False
|
|
248
|
+
else:
|
|
249
|
+
self.trading, self._owns_trading = PolymarketUSExchangeTrading(source), True
|
|
250
|
+
if isinstance(protos, ProtoBundle):
|
|
251
|
+
bundle: ProtoBundle | None = protos
|
|
252
|
+
elif protos is None and kwargs.get("call") is not None:
|
|
253
|
+
bundle = None
|
|
254
|
+
else:
|
|
255
|
+
bundle = load_protos(protos)
|
|
256
|
+
if self.data_heartbeat:
|
|
257
|
+
kwargs.setdefault("idle_timeout", HEARTBEAT_IDLE_S)
|
|
258
|
+
super().__init__(target or TARGETS[self.trading.credentials.env], protos=bundle, **kwargs)
|
|
259
|
+
self.seen = RecentIds()
|
|
260
|
+
|
|
261
|
+
async def metadata(self) -> list[tuple[str, str]]:
|
|
262
|
+
metadata = [("authorization", f"Bearer {await self.trading.tokens.token()}")]
|
|
263
|
+
if self.account_scoped:
|
|
264
|
+
metadata.append(("x-participant-id", self.trading.credentials.participant_id))
|
|
265
|
+
return metadata
|
|
266
|
+
|
|
267
|
+
async def on_reauthenticate(self) -> None:
|
|
268
|
+
self.trading.tokens.invalidate()
|
|
269
|
+
|
|
270
|
+
async def close(self) -> None:
|
|
271
|
+
await super().close()
|
|
272
|
+
if self._owns_trading:
|
|
273
|
+
await self.trading.close()
|
|
274
|
+
|
|
275
|
+
def account_for(self, name: str) -> Account | None:
|
|
276
|
+
if not name or name == self.trading.trading_account:
|
|
277
|
+
return self.trading.account
|
|
278
|
+
return Account(venue=VENUE, name=name)
|
|
279
|
+
|
|
280
|
+
# -- reference data -------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
def symbols_needing_scales(self, message: dict[str, Any]) -> list[str]:
|
|
283
|
+
return []
|
|
284
|
+
|
|
285
|
+
async def prepare(self, message: dict[str, Any]) -> None:
|
|
286
|
+
missing = [s for s in dict.fromkeys(self.symbols_needing_scales(message)) if s and self.trading.cached_scale(s) is None]
|
|
287
|
+
if missing:
|
|
288
|
+
try:
|
|
289
|
+
await self.trading.load_instruments(missing, strict=False)
|
|
290
|
+
except ExchangeError as exc:
|
|
291
|
+
self.status("error", f"reference data for {missing[:3]} failed: {exc}")
|
|
292
|
+
|
|
293
|
+
def _each(self, items: Any, read: Callable[[dict[str, Any]], list[Event]]) -> list[Event]:
|
|
294
|
+
"""Read items one at a time, so one unreadable item costs only itself."""
|
|
295
|
+
events: list[Event] = []
|
|
296
|
+
for item in items or []:
|
|
297
|
+
try:
|
|
298
|
+
events.extend(read(item))
|
|
299
|
+
except Exception as exc:
|
|
300
|
+
self.status("error", f"unreadable item: {type(exc).__name__}: {exc}")
|
|
301
|
+
return events
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class _ResumableStream(_ExchangeStream):
|
|
305
|
+
"""A drop-copy RPC: every response carries a token to resume after it."""
|
|
306
|
+
|
|
307
|
+
items_field = ""
|
|
308
|
+
refused_token_codes = {"INVALID_ARGUMENT", "FAILED_PRECONDITION", "OUT_OF_RANGE", "NOT_FOUND"}
|
|
309
|
+
|
|
310
|
+
def __init__(
|
|
311
|
+
self,
|
|
312
|
+
source: PolymarketUSExchangeCredentials | PolymarketUSExchangeTrading,
|
|
313
|
+
*,
|
|
314
|
+
symbols: list[str] | None = None,
|
|
315
|
+
firms: list[str] | None = None,
|
|
316
|
+
resume_token: bytes | str | None = None,
|
|
317
|
+
resume_time: int | None = None,
|
|
318
|
+
**kwargs: Any,
|
|
319
|
+
):
|
|
320
|
+
super().__init__(source, **kwargs)
|
|
321
|
+
self.symbols = list(symbols or [])
|
|
322
|
+
self.firms = list(firms or [])
|
|
323
|
+
if isinstance(resume_token, bytes):
|
|
324
|
+
resume_token = base64.b64encode(resume_token).decode()
|
|
325
|
+
self.resume_token: str | None = resume_token or None
|
|
326
|
+
"""The last token, base64 as the venue's JSON mapping writes bytes."""
|
|
327
|
+
self.resume_time: int | None = resume_time
|
|
328
|
+
"""Where to resume from without a token, in milliseconds."""
|
|
329
|
+
self.last_event_time: int | None = None
|
|
330
|
+
|
|
331
|
+
request_fields: tuple[str, ...] = ("symbols", "firms")
|
|
332
|
+
|
|
333
|
+
def request(self) -> dict[str, Any] | None:
|
|
334
|
+
request: dict[str, Any] = {"symbols": self.symbols}
|
|
335
|
+
if "firms" in self.request_fields:
|
|
336
|
+
request["firms"] = self.firms
|
|
337
|
+
if self.resume_token:
|
|
338
|
+
request["resumeToken"] = self.resume_token
|
|
339
|
+
elif self.resume_time is not None:
|
|
340
|
+
request["resumeTime"] = rfc3339(self.resume_time)
|
|
341
|
+
if self.resume_time is None and not self.resume_token:
|
|
342
|
+
# Nothing to resume from yet: a reconnect before the first token
|
|
343
|
+
# replays from here.
|
|
344
|
+
self.resume_time = now_ms()
|
|
345
|
+
return request
|
|
346
|
+
|
|
347
|
+
def reconcile_on_reconnect(self) -> bool:
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
def classify(self, code: str | None) -> Outcome:
|
|
351
|
+
if code in self.refused_token_codes and self.resume_token:
|
|
352
|
+
since = self.last_event_time if self.last_event_time is not None else self.resume_time
|
|
353
|
+
self.resume_token = None
|
|
354
|
+
self.resume_time = since
|
|
355
|
+
self.status("error", f"the venue refused the resume token ({code}); resuming from {rfc3339(since) if since else 'now'}")
|
|
356
|
+
return "retry"
|
|
357
|
+
return super().classify(code)
|
|
358
|
+
|
|
359
|
+
def event_time(self, item: dict[str, Any]) -> int | None:
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
def read(self, item: dict[str, Any]) -> list[Event]:
|
|
363
|
+
return []
|
|
364
|
+
|
|
365
|
+
def handle(self, message: dict[str, Any]) -> list[Event]:
|
|
366
|
+
def one(item: dict[str, Any]) -> list[Event]:
|
|
367
|
+
stamp = self.event_time(item)
|
|
368
|
+
if stamp is not None and (self.last_event_time is None or stamp > self.last_event_time):
|
|
369
|
+
self.last_event_time = stamp
|
|
370
|
+
return self.read(item)
|
|
371
|
+
|
|
372
|
+
events = self._each(message.get(self.items_field), one)
|
|
373
|
+
token = message.get("resumeToken") or None
|
|
374
|
+
if token and token != self.resume_token:
|
|
375
|
+
self.resume_token = token
|
|
376
|
+
events.append(VenueEvent(venue=VENUE, name="checkpoint", timestamp=self.last_event_time, payload={
|
|
377
|
+
"stream": self.name, "resume_token": token,
|
|
378
|
+
}))
|
|
379
|
+
return events
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class PolymarketUSExchangeDropCopyStream(_ResumableStream):
|
|
383
|
+
"""Every execution in the firm, from the post-trade copy: orders and fills
|
|
384
|
+
for all accounts, resumable across reconnects and restarts."""
|
|
385
|
+
|
|
386
|
+
name = "drop_copy"
|
|
387
|
+
method = "polymarket.v1.DropCopyAPI/CreateDropCopySubscription"
|
|
388
|
+
items_field = "executions"
|
|
389
|
+
private = True
|
|
390
|
+
has: dict[str, Capability] = {"watch_orders": True, "watch_my_trades": True}
|
|
391
|
+
|
|
392
|
+
def symbols_needing_scales(self, message: dict[str, Any]) -> list[str]:
|
|
393
|
+
return [str((e.get("order") or {}).get("symbol") or "") for e in message.get("executions") or [] if not _carries_scale(e.get("order") or {})]
|
|
394
|
+
|
|
395
|
+
def event_time(self, item: dict[str, Any]) -> int | None:
|
|
396
|
+
return parse_ts(item.get("transactTime"))
|
|
397
|
+
|
|
398
|
+
def read(self, item: dict[str, Any]) -> list[Event]:
|
|
399
|
+
key = item.get("id")
|
|
400
|
+
if key and self.seen.seen(key):
|
|
401
|
+
return []
|
|
402
|
+
raw_order = item.get("order") or {}
|
|
403
|
+
symbol = str(raw_order.get("symbol") or "")
|
|
404
|
+
return execution_events(item, self.trading.cached_scale(symbol), account=self.account_for(str(raw_order.get("account") or "")))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class PolymarketUSExchangeTradeCaptureStream(_ResumableStream):
|
|
408
|
+
"""Trades as they move through clearing. Each report is the firm's fills
|
|
409
|
+
with `settlement` updated; a bust arrives as the same fill `failed`."""
|
|
410
|
+
|
|
411
|
+
name = "trade_capture"
|
|
412
|
+
method = "polymarket.v1.DropCopyAPI/CreateTradeCaptureReportSubscription"
|
|
413
|
+
items_field = "tradeCaptureReports"
|
|
414
|
+
private = True
|
|
415
|
+
has: dict[str, Capability] = {"watch_my_trades": True}
|
|
416
|
+
|
|
417
|
+
def symbols_needing_scales(self, message: dict[str, Any]) -> list[str]:
|
|
418
|
+
out = []
|
|
419
|
+
for trade in message.get("tradeCaptureReports") or []:
|
|
420
|
+
for role in ("aggressor", "passive"):
|
|
421
|
+
order = (trade.get(role) or {}).get("order") or {}
|
|
422
|
+
if order and not _carries_scale(order):
|
|
423
|
+
out.append(str(order.get("symbol") or ""))
|
|
424
|
+
return out
|
|
425
|
+
|
|
426
|
+
def event_time(self, item: dict[str, Any]) -> int | None:
|
|
427
|
+
stamps = [parse_ts((item.get(role) or {}).get("transactTime")) for role in ("aggressor", "passive")]
|
|
428
|
+
return max((s for s in stamps if s is not None), default=None)
|
|
429
|
+
|
|
430
|
+
def read(self, item: dict[str, Any]) -> list[Event]:
|
|
431
|
+
key = (item.get("id"), item.get("state"), item.get("reportingCounterparty"))
|
|
432
|
+
if item.get("id") and self.seen.seen(key):
|
|
433
|
+
return []
|
|
434
|
+
return [FillEvent(venue=VENUE, fill=f) for f in trade_fills(item, self.trading.cached_scale, account_for=self.account_for)]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class PolymarketUSExchangePositionChangeStream(_ResumableStream):
|
|
438
|
+
"""Position changes across the firm, resumable."""
|
|
439
|
+
|
|
440
|
+
name = "position_changes"
|
|
441
|
+
method = "polymarket.v1.DropCopyAPI/CreatePositionChangeSubscription"
|
|
442
|
+
items_field = "positionChanges"
|
|
443
|
+
private = True
|
|
444
|
+
has: dict[str, Capability] = {"watch_positions": True}
|
|
445
|
+
|
|
446
|
+
def symbols_needing_scales(self, message: dict[str, Any]) -> list[str]:
|
|
447
|
+
return [str((c.get("position") or {}).get("symbol") or "") for c in message.get("positionChanges") or []]
|
|
448
|
+
|
|
449
|
+
def event_time(self, item: dict[str, Any]) -> int | None:
|
|
450
|
+
return parse_ts(item.get("changeTime"))
|
|
451
|
+
|
|
452
|
+
def read(self, item: dict[str, Any]) -> list[Event]:
|
|
453
|
+
raw = item.get("position") or {}
|
|
454
|
+
symbol = str(raw.get("symbol") or "")
|
|
455
|
+
scale = _require(self.trading.cached_scale(symbol), symbol)
|
|
456
|
+
return [PositionEvent(venue=VENUE, position=position_of(raw, scale, account=self.account_for(str(raw.get("account") or ""))))]
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class PolymarketUSExchangeInstrumentStream(_ResumableStream):
|
|
460
|
+
"""Instrument state changes: pending, open, halted, closed, expired,
|
|
461
|
+
terminated. Needs no participant id."""
|
|
462
|
+
|
|
463
|
+
name = "instruments"
|
|
464
|
+
method = "polymarket.v1.DropCopyAPI/CreateInstrumentStateChangeSubscription"
|
|
465
|
+
items_field = "instruments"
|
|
466
|
+
account_scoped = False
|
|
467
|
+
request_fields = ("symbols",)
|
|
468
|
+
has: dict[str, Capability] = {"watch_market_status": True}
|
|
469
|
+
|
|
470
|
+
def event_time(self, item: dict[str, Any]) -> int | None:
|
|
471
|
+
return parse_ts(item.get("updateTime"))
|
|
472
|
+
|
|
473
|
+
def read(self, item: dict[str, Any]) -> list[Event]:
|
|
474
|
+
event = market_status_of(item)
|
|
475
|
+
if self.seen.seen((event.market_id, event.native, item.get("updateTime"))):
|
|
476
|
+
return []
|
|
477
|
+
if _carries_scale(item) and item.get("symbol"):
|
|
478
|
+
# Reference data for free: remember the scales it carries.
|
|
479
|
+
self.trading.remember_instrument(InstrumentScale.from_instrument(item))
|
|
480
|
+
return [event]
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class PolymarketUSExchangeOrderStream(_ExchangeStream):
|
|
484
|
+
"""This participant's orders: a snapshot of open orders on every connect,
|
|
485
|
+
then executions -- acceptance, fills, cancels, replaces, rejects -- with
|
|
486
|
+
fills also as `FillEvent`s, and refused cancels as
|
|
487
|
+
`VenueEvent(name="cancel_rejected")`. Not replayed after a reconnect."""
|
|
488
|
+
|
|
489
|
+
name = "orders"
|
|
490
|
+
method = "polymarket.v1.OrderEntryAPI/CreateOrderSubscription"
|
|
491
|
+
private = True
|
|
492
|
+
data_heartbeat = True
|
|
493
|
+
has: dict[str, Capability] = {"watch_orders": True, "watch_my_trades": True}
|
|
494
|
+
|
|
495
|
+
def __init__(self, source: Any, *, symbols: list[str] | None = None, accounts: list[str] | None = None, **kwargs: Any):
|
|
496
|
+
super().__init__(source, **kwargs)
|
|
497
|
+
self.symbols = list(symbols or [])
|
|
498
|
+
self.accounts = list(accounts or [])
|
|
499
|
+
self.session_id: str | None = None
|
|
500
|
+
|
|
501
|
+
async def watch_orders(self, market_ids: list[str] | None = None, accounts: list[str] | None = None) -> None:
|
|
502
|
+
symbols = None if market_ids is None else [ids.native(VENUE, m) for m in market_ids]
|
|
503
|
+
"""Narrow (or widen, with `None`) what the stream carries; empty means everything."""
|
|
504
|
+
symbols, accounts = list(symbols or []), list(accounts or [])
|
|
505
|
+
if (symbols, accounts) != (self.symbols, self.accounts):
|
|
506
|
+
self.symbols, self.accounts = symbols, accounts
|
|
507
|
+
self.resubscribe()
|
|
508
|
+
self.start()
|
|
509
|
+
|
|
510
|
+
watch_my_trades = watch_orders
|
|
511
|
+
|
|
512
|
+
def request(self) -> dict[str, Any] | None:
|
|
513
|
+
return {"symbols": self.symbols, "accounts": self.accounts, "snapshotOnly": False}
|
|
514
|
+
|
|
515
|
+
def symbols_needing_scales(self, message: dict[str, Any]) -> list[str]:
|
|
516
|
+
orders = list((message.get("snapshot") or {}).get("orders") or [])
|
|
517
|
+
orders += [e.get("order") or {} for e in (message.get("update") or {}).get("executions") or []]
|
|
518
|
+
return [str(o.get("symbol") or "") for o in orders if not _carries_scale(o)]
|
|
519
|
+
|
|
520
|
+
def _order(self, raw: dict[str, Any]) -> list[Event]:
|
|
521
|
+
symbol = str(raw.get("symbol") or "")
|
|
522
|
+
scale = _require(scale_from_order(raw, self.trading.cached_scale(symbol)), symbol)
|
|
523
|
+
return [OrderEvent(venue=VENUE, order=order_of(raw, scale, account=self.account_for(str(raw.get("account") or ""))), native="snapshot")]
|
|
524
|
+
|
|
525
|
+
def _execution(self, item: dict[str, Any]) -> list[Event]:
|
|
526
|
+
if item.get("id") and self.seen.seen(item["id"]):
|
|
527
|
+
return []
|
|
528
|
+
raw_order = item.get("order") or {}
|
|
529
|
+
return execution_events(item, self.trading.cached_scale(str(raw_order.get("symbol") or "")), account=self.account_for(str(raw_order.get("account") or "")))
|
|
530
|
+
|
|
531
|
+
def handle(self, message: dict[str, Any]) -> list[Event]:
|
|
532
|
+
self.session_id = message.get("sessionId") or self.session_id
|
|
533
|
+
if "snapshot" in message:
|
|
534
|
+
return self._each((message["snapshot"] or {}).get("orders"), self._order)
|
|
535
|
+
update = message.get("update")
|
|
536
|
+
if update is None:
|
|
537
|
+
return []
|
|
538
|
+
events = self._each(update.get("executions"), self._execution)
|
|
539
|
+
reject = update.get("cancelReject")
|
|
540
|
+
if reject:
|
|
541
|
+
events.append(VenueEvent(venue=VENUE, name="cancel_rejected", timestamp=parse_ts(reject.get("transactTime")), payload={
|
|
542
|
+
"order_id": reject.get("orderId") or None, "client_order_id": reject.get("clordId") or None,
|
|
543
|
+
"reason": reject.get("rejectReason"), "text": reject.get("text") or "", "is_replace": bool(reject.get("isReplace")),
|
|
544
|
+
}))
|
|
545
|
+
return events
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class PolymarketUSExchangePositionStream(_ExchangeStream):
|
|
549
|
+
"""Positions: a snapshot on every connect, then changes. The snapshot
|
|
550
|
+
restates everything, so a reconnect needs no reconciliation; a position
|
|
551
|
+
held before and missing from a new snapshot is reported flat."""
|
|
552
|
+
|
|
553
|
+
name = "positions"
|
|
554
|
+
method = "polymarket.v1.PositionAPI/CreatePositionSubscription"
|
|
555
|
+
private = True
|
|
556
|
+
data_heartbeat = True
|
|
557
|
+
has: dict[str, Capability] = {"watch_positions": True}
|
|
558
|
+
|
|
559
|
+
def __init__(self, source: Any, *, accounts: list[str] | None = None, **kwargs: Any):
|
|
560
|
+
super().__init__(source, **kwargs)
|
|
561
|
+
self.accounts = list(accounts or [])
|
|
562
|
+
self._held: dict[tuple[str, str], Account | None] = {}
|
|
563
|
+
|
|
564
|
+
def request(self) -> dict[str, Any] | None:
|
|
565
|
+
return {"accounts": self.accounts}
|
|
566
|
+
|
|
567
|
+
def reconcile_on_reconnect(self) -> bool:
|
|
568
|
+
return False
|
|
569
|
+
|
|
570
|
+
def symbols_needing_scales(self, message: dict[str, Any]) -> list[str]:
|
|
571
|
+
body = message.get("snapshot") or message.get("update") or {}
|
|
572
|
+
return [str(p.get("symbol") or "") for p in body.get("positions") or []]
|
|
573
|
+
|
|
574
|
+
def _position(self, raw: dict[str, Any]) -> list[Event]:
|
|
575
|
+
symbol = str(raw.get("symbol") or "")
|
|
576
|
+
scale = _require(self.trading.cached_scale(symbol), symbol)
|
|
577
|
+
name = str(raw.get("account") or "")
|
|
578
|
+
position = position_of(raw, scale, account=self.account_for(name))
|
|
579
|
+
key = (name, symbol)
|
|
580
|
+
if position.side == PositionSide.FLAT:
|
|
581
|
+
self._held.pop(key, None)
|
|
582
|
+
else:
|
|
583
|
+
self._held[key] = position.account
|
|
584
|
+
return [PositionEvent(venue=VENUE, position=position)]
|
|
585
|
+
|
|
586
|
+
def handle(self, message: dict[str, Any]) -> list[Event]:
|
|
587
|
+
if "snapshot" in message:
|
|
588
|
+
before = dict(self._held)
|
|
589
|
+
self._held.clear()
|
|
590
|
+
events = self._each((message["snapshot"] or {}).get("positions"), self._position)
|
|
591
|
+
for (name, symbol), account in before.items():
|
|
592
|
+
if (name, symbol) not in self._held:
|
|
593
|
+
events.append(PositionEvent(venue=VENUE, position=flat_position(account, symbol)))
|
|
594
|
+
return events
|
|
595
|
+
update = message.get("update")
|
|
596
|
+
return self._each((update or {}).get("positions"), self._position)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class PolymarketUSExchangeMarketDataStream(_ExchangeStream):
|
|
600
|
+
"""Books to a depth and market statistics for up to a thousand symbols.
|
|
601
|
+
|
|
602
|
+
Each update is taken as the whole book to the subscribed depth and
|
|
603
|
+
replaces the local one, kept on the YES leg: `book("<symbol>:no")` is the
|
|
604
|
+
mirrored view. Adding symbols reopens the call with the full set."""
|
|
605
|
+
|
|
606
|
+
name = "market_data"
|
|
607
|
+
method = "polymarket.v1.MarketDataSubscriptionAPI/CreateMarketDataSubscription"
|
|
608
|
+
account_scoped = False
|
|
609
|
+
data_heartbeat = True
|
|
610
|
+
has: dict[str, Capability] = {"watch_order_book": True, "watch_ticker": True, "watch_trades": False, "watch_market_status": False}
|
|
611
|
+
|
|
612
|
+
def __init__(self, source: Any, *, depth: int = 10, **kwargs: Any):
|
|
613
|
+
super().__init__(source, **kwargs)
|
|
614
|
+
self.depth = depth
|
|
615
|
+
self.symbols: list[str] = []
|
|
616
|
+
self.books: dict[str, LocalBook] = {}
|
|
617
|
+
|
|
618
|
+
async def watch_order_book(self, market_ids: list[str]) -> None:
|
|
619
|
+
symbols = [ids.native(VENUE, m) for m in market_ids]
|
|
620
|
+
added = [s for s in dict.fromkeys(symbols) if s not in self.symbols]
|
|
621
|
+
if len(self.symbols) + len(added) > MAX_SYMBOLS:
|
|
622
|
+
raise ValueError(f"polymarket_us: one market-data stream carries at most {MAX_SYMBOLS} symbols; open another")
|
|
623
|
+
if added:
|
|
624
|
+
self.symbols.extend(added)
|
|
625
|
+
self.resubscribe()
|
|
626
|
+
self.start()
|
|
627
|
+
|
|
628
|
+
watch_ticker = watch_order_book
|
|
629
|
+
|
|
630
|
+
def book(self, market_id: str, side: str = "yes") -> LocalBook | None:
|
|
631
|
+
book = self.books.get(ids.native(VENUE, market_id))
|
|
632
|
+
if book is None or side != "no":
|
|
633
|
+
return book
|
|
634
|
+
return book.mirrored()
|
|
635
|
+
|
|
636
|
+
def request(self) -> dict[str, Any] | None:
|
|
637
|
+
if not self.symbols:
|
|
638
|
+
return None
|
|
639
|
+
return {"symbols": list(self.symbols), "depth": self.depth, "unaggregated": False, "snapshotOnly": False}
|
|
640
|
+
|
|
641
|
+
async def before_call(self, request: dict[str, Any]) -> dict[str, Any] | None:
|
|
642
|
+
missing = [s for s in request["symbols"] if self.trading.cached_scale(s) is None]
|
|
643
|
+
if missing:
|
|
644
|
+
await self.trading.load_instruments(missing, strict=False)
|
|
645
|
+
unknown = [s for s in request["symbols"] if self.trading.cached_scale(s) is None]
|
|
646
|
+
if unknown:
|
|
647
|
+
self.status("error", f"no reference data for {unknown[:5]}; not subscribed")
|
|
648
|
+
self.symbols = [s for s in self.symbols if s not in unknown]
|
|
649
|
+
request = {**request, "symbols": [s for s in request["symbols"] if s not in unknown]}
|
|
650
|
+
return request if request["symbols"] else None
|
|
651
|
+
|
|
652
|
+
def on_disconnect(self) -> None:
|
|
653
|
+
for book in self.books.values():
|
|
654
|
+
book.invalidate()
|
|
655
|
+
|
|
656
|
+
def handle(self, message: dict[str, Any]) -> list[Event]:
|
|
657
|
+
update = message.get("update")
|
|
658
|
+
if not update:
|
|
659
|
+
return []
|
|
660
|
+
symbol = str(update.get("symbol") or "")
|
|
661
|
+
scale = _require(self.trading.cached_scale(symbol), symbol)
|
|
662
|
+
stamp = parse_ts(update.get("transactTime"))
|
|
663
|
+
book = self.books.setdefault(symbol, LocalBook())
|
|
664
|
+
bids, asks = book_sides(update, scale)
|
|
665
|
+
hidden = bool(update.get("bookHidden"))
|
|
666
|
+
book.replace(bids, asks)
|
|
667
|
+
book.timestamp = stamp
|
|
668
|
+
if hidden:
|
|
669
|
+
book.invalidate()
|
|
670
|
+
levels_bids, levels_asks = book.levels()
|
|
671
|
+
events: list[Event] = [BookEvent(
|
|
672
|
+
venue=VENUE, market_id=ids.qualify(VENUE, symbol), kind="snapshot", bids=levels_bids,
|
|
673
|
+
asks=levels_asks, best_bid=book.best_bid, best_ask=book.best_ask, timestamp=stamp,
|
|
674
|
+
info={"book_hidden": True} if hidden else {},
|
|
675
|
+
)]
|
|
676
|
+
stats = update.get("stats")
|
|
677
|
+
if stats:
|
|
678
|
+
events.append(QuoteEvent(
|
|
679
|
+
venue=VENUE, market_id=ids.qualify(VENUE, symbol),
|
|
680
|
+
bid=book.best_bid, ask=book.best_ask,
|
|
681
|
+
bid_size=book.bids.get(book.best_bid) if book.best_bid is not None else None,
|
|
682
|
+
ask_size=book.asks.get(book.best_ask) if book.best_ask is not None else None,
|
|
683
|
+
last=scale.price_from_wire(stats.get("lastTradePx")),
|
|
684
|
+
volume=scale.qty_from_wire(stats["sharesTraded"]) if "sharesTraded" in stats else None,
|
|
685
|
+
open_interest=scale.qty_from_wire(stats["openInterest"]) if "openInterest" in stats else None,
|
|
686
|
+
timestamp=stamp, info=stats,
|
|
687
|
+
))
|
|
688
|
+
return events
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
class PolymarketUSExchangeBalanceLedgerStream(_ExchangeStream):
|
|
692
|
+
"""Every change to one account's balance -- deposits, withdrawals,
|
|
693
|
+
executions, commissions, resolutions -- as
|
|
694
|
+
`VenueEvent(name="balance_ledger")` with `before`, `after` and `change`.
|
|
695
|
+
|
|
696
|
+
Replayable by time: a reconnect asks again from the last entry's time and
|
|
697
|
+
duplicates are dropped by id, so nothing is missed. Pass `resume_time=`
|
|
698
|
+
(milliseconds) to replay after a restart; the venue keeps entries from
|
|
699
|
+
2026-05-01."""
|
|
700
|
+
|
|
701
|
+
name = "balance_ledger"
|
|
702
|
+
method = "polymarket.v1.FundingAPI/CreateBalanceLedgerSubscription"
|
|
703
|
+
private = True
|
|
704
|
+
has: dict[str, Capability] = {
|
|
705
|
+
# Ledger entries carry the cash balance, not what is available or
|
|
706
|
+
# locked: `fetch_balance` remains the source for those.
|
|
707
|
+
"watch_balance": "partial",
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
def __init__(
|
|
711
|
+
self,
|
|
712
|
+
source: Any,
|
|
713
|
+
*,
|
|
714
|
+
account: str | None = None,
|
|
715
|
+
currency: str = "",
|
|
716
|
+
entry_types: list[str] | None = None,
|
|
717
|
+
resume_time: int | None = None,
|
|
718
|
+
**kwargs: Any,
|
|
719
|
+
):
|
|
720
|
+
super().__init__(source, **kwargs)
|
|
721
|
+
self.ledger_account = account
|
|
722
|
+
self.currency = currency
|
|
723
|
+
self.entry_types = list(entry_types or [])
|
|
724
|
+
self.resume_time = resume_time
|
|
725
|
+
self._started_at: int | None = None
|
|
726
|
+
|
|
727
|
+
def request(self) -> dict[str, Any] | None:
|
|
728
|
+
request: dict[str, Any] = {"account": self.ledger_account or "", "currency": self.currency, "entryTypes": self.entry_types}
|
|
729
|
+
since = self.resume_time if self.resume_time is not None else (self._started_at if self.stats.connects else None)
|
|
730
|
+
if since is not None:
|
|
731
|
+
request["resumeTime"] = rfc3339(since)
|
|
732
|
+
if self._started_at is None:
|
|
733
|
+
self._started_at = now_ms()
|
|
734
|
+
return request
|
|
735
|
+
|
|
736
|
+
async def before_call(self, request: dict[str, Any]) -> dict[str, Any] | None:
|
|
737
|
+
if not request["account"]:
|
|
738
|
+
self.ledger_account = await self.trading._account()
|
|
739
|
+
request = {**request, "account": self.ledger_account}
|
|
740
|
+
return request
|
|
741
|
+
|
|
742
|
+
def reconcile_on_reconnect(self) -> bool:
|
|
743
|
+
return False
|
|
744
|
+
|
|
745
|
+
def _entry(self, entry: dict[str, Any]) -> list[Event]:
|
|
746
|
+
stamp = parse_ts(entry.get("updateTime"))
|
|
747
|
+
if stamp is not None and (self.resume_time is None or stamp > self.resume_time):
|
|
748
|
+
self.resume_time = stamp
|
|
749
|
+
if entry.get("id") and self.seen.seen(entry["id"]):
|
|
750
|
+
return []
|
|
751
|
+
return [ledger_event(entry)]
|
|
752
|
+
|
|
753
|
+
def handle(self, message: dict[str, Any]) -> list[Event]:
|
|
754
|
+
return self._each(message.get("entries"), self._entry)
|