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/types.py
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"""The unified shapes every venue is normalized into.
|
|
2
|
+
|
|
3
|
+
Three rules run through all of them, and they are the reason this library
|
|
4
|
+
exists rather than being a thin wrapper:
|
|
5
|
+
|
|
6
|
+
1. **Absence is `None`, never a number.** Kalshi prints 0 for a bid nobody is
|
|
7
|
+
offering, 1 for an absent ask, and 0 for a market that has never traded.
|
|
8
|
+
Those are placeholders, not prices, and they are stored as `None` here. A
|
|
9
|
+
library that passes them through tells you a market is worth nothing when
|
|
10
|
+
it means nobody has quoted it.
|
|
11
|
+
|
|
12
|
+
2. **No single `price` field.** `last`, `bid`, `ask` and `mid` are four
|
|
13
|
+
different numbers that disagree, sometimes wildly, and collapsing them into
|
|
14
|
+
one hides which you got. A thin market can show a months-old last trade at
|
|
15
|
+
5c sitting on a live 0.2c/1.3c book; next to another venue's 0.05c that
|
|
16
|
+
reads as a 100x disagreement between two books that both say "near zero".
|
|
17
|
+
|
|
18
|
+
3. **Every timestamp is milliseconds since epoch (`timestamp`) plus an ISO
|
|
19
|
+
8601 string (`datetime`)**, the ccxt convention, and every object carries
|
|
20
|
+
`info` with the venue's untouched payload.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
from datetime import datetime as _dt, timezone
|
|
26
|
+
from typing import Any, Generic, Literal, TypeVar
|
|
27
|
+
|
|
28
|
+
from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
|
|
29
|
+
|
|
30
|
+
MarketStatus = Literal["unopened", "open", "closed", "settled"]
|
|
31
|
+
"""Normalized lifecycle. `native_status` always carries the venue's own word.
|
|
32
|
+
|
|
33
|
+
unopened — listed but not yet accepting orders
|
|
34
|
+
open — trading
|
|
35
|
+
closed — trading has stopped, outcome not yet final
|
|
36
|
+
settled — outcome final and paid
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
BookSide = Literal["yes", "no"]
|
|
40
|
+
"""Which side of a market a book or candle series is priced for."""
|
|
41
|
+
|
|
42
|
+
BookModel = Literal["shared_complement", "native_per_outcome"]
|
|
43
|
+
"""How a venue stores the book behind a market.
|
|
44
|
+
|
|
45
|
+
shared_complement — one book serves both sides (Kalshi, Polymarket US). The
|
|
46
|
+
NO view is a transform of the YES book, not a second book.
|
|
47
|
+
native_per_outcome — each side owns an independently addressable book
|
|
48
|
+
(Polymarket: one per CLOB token).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
PriceSource = Literal["trade", "sampled_mid", "bid_ask_mid"]
|
|
52
|
+
"""Where a candle's OHLC actually came from. See `Candle`."""
|
|
53
|
+
|
|
54
|
+
T = TypeVar("T")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ms(value: _dt | None) -> int | None:
|
|
58
|
+
"""A datetime as milliseconds since epoch, UTC. Naive input is read as UTC."""
|
|
59
|
+
if value is None:
|
|
60
|
+
return None
|
|
61
|
+
if value.tzinfo is None:
|
|
62
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
63
|
+
return int(value.timestamp() * 1000)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def iso(timestamp_ms: int | None) -> str | None:
|
|
67
|
+
"""Milliseconds since epoch as an ISO 8601 string in UTC."""
|
|
68
|
+
if timestamp_ms is None:
|
|
69
|
+
return None
|
|
70
|
+
return _dt.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Page(list, Generic[T]):
|
|
74
|
+
"""A list of results that also knows how to ask for the next page.
|
|
75
|
+
|
|
76
|
+
A plain `list`, so every caller that only wants the rows can ignore it
|
|
77
|
+
entirely. The cursor rides along as an attribute rather than being stashed
|
|
78
|
+
on the exchange object, because a cursor belongs to one response, not to
|
|
79
|
+
the client: two concurrent calls sharing an adapter would otherwise
|
|
80
|
+
overwrite each other's position in the catalog.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
page = kalshi.fetch_markets(limit=50)
|
|
84
|
+
while page.next_cursor:
|
|
85
|
+
page = kalshi.fetch_markets(limit=50, cursor=page.next_cursor)
|
|
86
|
+
```
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
__slots__ = ("next_cursor",)
|
|
90
|
+
|
|
91
|
+
def __init__(self, items: Any = (), *, next_cursor: str | None = None):
|
|
92
|
+
super().__init__(items)
|
|
93
|
+
self.next_cursor = next_cursor
|
|
94
|
+
|
|
95
|
+
def __repr__(self) -> str:
|
|
96
|
+
return f"Page({list.__repr__(self)}, next_cursor={self.next_cursor!r})"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Base(BaseModel):
|
|
100
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
101
|
+
|
|
102
|
+
@model_validator(mode="before")
|
|
103
|
+
@classmethod
|
|
104
|
+
def _drop_computed(cls, data: Any) -> Any:
|
|
105
|
+
"""Let a serialized model be read back in.
|
|
106
|
+
|
|
107
|
+
Computed fields are written on the way out but are not inputs, and
|
|
108
|
+
`extra="forbid"` would reject them on the way in — so a client that
|
|
109
|
+
read a response and sent it back, or any round trip through JSON,
|
|
110
|
+
would fail on a field this library itself added. Only the known
|
|
111
|
+
computed names are dropped; a genuine typo is still refused.
|
|
112
|
+
"""
|
|
113
|
+
computed = cls.model_computed_fields
|
|
114
|
+
if computed and isinstance(data, dict) and not computed.keys().isdisjoint(data):
|
|
115
|
+
return {key: value for key, value in data.items() if key not in computed}
|
|
116
|
+
return data
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Quote(_Base):
|
|
120
|
+
"""What one instrument is worth right now, with the provenance attached.
|
|
121
|
+
|
|
122
|
+
Four numbers, each independently nullable, because each is absent for its
|
|
123
|
+
own reason: no bids, no asks, no trades ever, or a stale book. `mid` is
|
|
124
|
+
`None` whenever either side is empty — it is never quietly replaced by the
|
|
125
|
+
one side that is quoted.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
bid: float | None = None
|
|
129
|
+
"""Best bid, 0-1. `None` when nobody is bidding."""
|
|
130
|
+
bid_size: float | None = None
|
|
131
|
+
ask: float | None = None
|
|
132
|
+
"""Best ask, 0-1. `None` when nobody is offering."""
|
|
133
|
+
ask_size: float | None = None
|
|
134
|
+
mid: float | None = None
|
|
135
|
+
"""`(bid + ask) / 2`, only when both sides are quoted."""
|
|
136
|
+
last: float | None = None
|
|
137
|
+
"""Last traded price. `None` if this instrument has never traded."""
|
|
138
|
+
last_timestamp: int | None = None
|
|
139
|
+
"""When `last` traded, in ms. Always read it alongside `last`: on a thin
|
|
140
|
+
market the last print can be months old."""
|
|
141
|
+
last_datetime: str | None = None
|
|
142
|
+
|
|
143
|
+
@computed_field # type: ignore[prop-decorator]
|
|
144
|
+
@property
|
|
145
|
+
def spread(self) -> float | None:
|
|
146
|
+
"""`ask - bid`, or `None` if either side is empty.
|
|
147
|
+
|
|
148
|
+
A computed field rather than a plain property, so it reaches HTTP
|
|
149
|
+
clients too. An accessor that exists only in Python is an accessor
|
|
150
|
+
every other language has to reimplement.
|
|
151
|
+
"""
|
|
152
|
+
if self.bid is None or self.ask is None:
|
|
153
|
+
return None
|
|
154
|
+
return round(self.ask - self.bid, 6)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Outcome(_Base):
|
|
158
|
+
"""One side of a market: YES or NO, with its own quote.
|
|
159
|
+
|
|
160
|
+
An outcome has no id of its own. A market is the unit that is identified
|
|
161
|
+
and traded, and the side is said on the order (`buy` takes YES, `sell`
|
|
162
|
+
takes NO) or asked for on a book (`side="no"`). Every binary market on
|
|
163
|
+
every venue has exactly two; the venues differ in whether the two share a
|
|
164
|
+
book, which `Market.book_model` records.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
label: str
|
|
168
|
+
"""The venue's display text ("Yes", "No", "Up", a candidate name). Never
|
|
169
|
+
used to decide financial logic: which of `market.yes` / `market.no` an
|
|
170
|
+
outcome sits in is."""
|
|
171
|
+
quote: Quote = Field(default_factory=Quote)
|
|
172
|
+
"""In this outcome's own price convention: the NO quote is what NO costs."""
|
|
173
|
+
venue_token_id: str | None = None
|
|
174
|
+
"""The venue's own id for this side, where it has one. Polymarket only:
|
|
175
|
+
the CLOB token id the venue's order books and orders are keyed on."""
|
|
176
|
+
price_change_24h: float | None = None
|
|
177
|
+
"""Absolute probability delta over 24h, when the venue publishes it."""
|
|
178
|
+
info: dict[str, Any] = Field(default_factory=dict)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class MarketStats(_Base):
|
|
182
|
+
"""Venue-reported headline numbers, with their units spelled out.
|
|
183
|
+
|
|
184
|
+
`volume_unit` is not decoration. Kalshi counts contracts, Polymarket counts
|
|
185
|
+
collateral. Comparing the two numbers without converting is a category
|
|
186
|
+
error, and every unified API that labels both "USD" invites it.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
volume_24h: float | None = None
|
|
190
|
+
volume_total: float | None = None
|
|
191
|
+
liquidity: float | None = None
|
|
192
|
+
open_interest: float | None = None
|
|
193
|
+
volume_unit: Literal["contracts", "collateral"] | None = None
|
|
194
|
+
liquidity_unit: Literal["contracts", "collateral"] | None = None
|
|
195
|
+
as_of: int | None = None
|
|
196
|
+
"""When the venue says these numbers were current, in ms."""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Market(_Base):
|
|
200
|
+
"""A single settleable contract — the thing that actually resolves."""
|
|
201
|
+
|
|
202
|
+
id: str
|
|
203
|
+
"""Synpath's id: `venue:native`, e.g. `kalshi:KXFEDDECISION-26SEP-C25` or
|
|
204
|
+
`polymarket:2252244`. One per listing on one venue. The part after the
|
|
205
|
+
colon is the venue's own id, also on `venue_market_id`."""
|
|
206
|
+
venue: str
|
|
207
|
+
venue_market_id: str
|
|
208
|
+
"""The venue's own id, unprefixed: a Kalshi ticker, a Polymarket Gamma id,
|
|
209
|
+
a Polymarket US slug."""
|
|
210
|
+
event_id: str | None = None
|
|
211
|
+
"""The parent event's Synpath id, `venue:native`."""
|
|
212
|
+
title: str
|
|
213
|
+
description: str | None = None
|
|
214
|
+
"""Resolution criteria, verbatim from the venue."""
|
|
215
|
+
slug: str | None = None
|
|
216
|
+
yes: Outcome
|
|
217
|
+
"""The YES side, by position in the venue's payload rather than by label
|
|
218
|
+
text: some Kalshi markets label both sides identically."""
|
|
219
|
+
no: Outcome
|
|
220
|
+
status: MarketStatus
|
|
221
|
+
native_status: str | None = None
|
|
222
|
+
"""The venue's own status word, untranslated."""
|
|
223
|
+
active: bool = False
|
|
224
|
+
"""Accepting orders right now. Derived, and deliberately separate from
|
|
225
|
+
`status`: a venue can halt trading without changing lifecycle state."""
|
|
226
|
+
market_type: Literal["binary", "categorical", "scalar", "unknown"] = "binary"
|
|
227
|
+
open_timestamp: int | None = None
|
|
228
|
+
close_timestamp: int | None = None
|
|
229
|
+
resolution_timestamp: int | None = None
|
|
230
|
+
"""The venue's *scheduled* resolution time, not when it actually resolved."""
|
|
231
|
+
open_datetime: str | None = None
|
|
232
|
+
close_datetime: str | None = None
|
|
233
|
+
resolution_datetime: str | None = None
|
|
234
|
+
tick_size: float | None = None
|
|
235
|
+
"""Minimum price increment. Needed to place an order that will be accepted."""
|
|
236
|
+
face_value: float = 1.0
|
|
237
|
+
"""What one contract pays at full settlement. Both venues pay 1.00 today.
|
|
238
|
+
Every complement transform reads this rather than hardcoding 1."""
|
|
239
|
+
book_model: BookModel = "native_per_outcome"
|
|
240
|
+
stats: MarketStats = Field(default_factory=MarketStats)
|
|
241
|
+
url: str | None = None
|
|
242
|
+
image_url: str | None = None
|
|
243
|
+
category: str | None = None
|
|
244
|
+
tags: list[str] = Field(default_factory=list)
|
|
245
|
+
series_id: str | None = None
|
|
246
|
+
"""The venue's recurring-series tier above the event. Kalshi keys its fee
|
|
247
|
+
schedule on this. `None` on venues without the concept."""
|
|
248
|
+
outcome_label: str | None = None
|
|
249
|
+
"""This market's short name inside its event.
|
|
250
|
+
|
|
251
|
+
An event like "Fed Decision in September?" holds one market per outcome,
|
|
252
|
+
and `title` is the whole question ("Will the Fed decrease interest rates by
|
|
253
|
+
50+ bps after the September meeting?") while this is the label the venue
|
|
254
|
+
lists it under ("50+ bps decrease"). Kalshi publishes it as the YES side's
|
|
255
|
+
subtitle, Polymarket as the market's group item title. Useful for display,
|
|
256
|
+
and for deciding whether two venues are offering the same option."""
|
|
257
|
+
neg_risk: bool | None = None
|
|
258
|
+
"""Whether this market belongs to a group where a NO position converts into
|
|
259
|
+
YES exposure on the others. `None` means unknown, never assumed False."""
|
|
260
|
+
settlement_sources: list[dict[str, Any]] = Field(default_factory=list)
|
|
261
|
+
"""Who decides the outcome, as `{"name", "url"}` entries.
|
|
262
|
+
|
|
263
|
+
Carried because `description` says what has to happen and this says who
|
|
264
|
+
rules on whether it did. Two venues can list the same question and settle
|
|
265
|
+
it off different sources, which is the difference between the same trade
|
|
266
|
+
and two different ones. Empty when the venue names no source."""
|
|
267
|
+
info: dict[str, Any] = Field(default_factory=dict)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class Event(_Base):
|
|
271
|
+
"""A real-world question domain grouping one or more markets."""
|
|
272
|
+
|
|
273
|
+
id: str
|
|
274
|
+
"""Synpath's id: `venue:native`, e.g. `kalshi:KXFEDDECISION-26SEP`."""
|
|
275
|
+
venue: str
|
|
276
|
+
venue_event_id: str
|
|
277
|
+
"""The venue's own id, unprefixed."""
|
|
278
|
+
title: str
|
|
279
|
+
description: str | None = None
|
|
280
|
+
slug: str | None = None
|
|
281
|
+
markets: list[Market] = Field(default_factory=list)
|
|
282
|
+
status: MarketStatus
|
|
283
|
+
native_status: str | None = None
|
|
284
|
+
category: str | None = None
|
|
285
|
+
tags: list[str] = Field(default_factory=list)
|
|
286
|
+
series_id: str | None = None
|
|
287
|
+
mutually_exclusive: bool | None = None
|
|
288
|
+
"""`None` is a real answer: the venue did not say. Never defaulted to False."""
|
|
289
|
+
close_timestamp: int | None = None
|
|
290
|
+
close_datetime: str | None = None
|
|
291
|
+
url: str | None = None
|
|
292
|
+
image_url: str | None = None
|
|
293
|
+
settlement_sources: list[dict[str, Any]] = Field(default_factory=list)
|
|
294
|
+
"""Who decides the outcomes under this event. On Kalshi these are published
|
|
295
|
+
per event rather than per market, so a market inherits its event's."""
|
|
296
|
+
info: dict[str, Any] = Field(default_factory=dict)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class OrderLevel(_Base):
|
|
300
|
+
price: float
|
|
301
|
+
"""0-1, in the price convention of the side the book was asked for."""
|
|
302
|
+
size: float
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class OrderBook(_Base):
|
|
306
|
+
"""Resting orders on one side of a market, as that side sees them.
|
|
307
|
+
|
|
308
|
+
On a `shared_complement` venue the NO side's book is computed from the YES
|
|
309
|
+
book (`bid = face_value - ask`, and the sides swap). `derived` records that
|
|
310
|
+
it happened, and `info` keeps the raw payload so the transform is auditable
|
|
311
|
+
rather than invisible.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
market_id: str
|
|
315
|
+
"""Synpath id, `venue:native`."""
|
|
316
|
+
side: BookSide = "yes"
|
|
317
|
+
"""Which side this book is priced for. Ask for `side="no"` to see what NO
|
|
318
|
+
costs; on a `shared_complement` venue that is the YES book mirrored."""
|
|
319
|
+
venue: str
|
|
320
|
+
bids: list[OrderLevel] = Field(default_factory=list)
|
|
321
|
+
"""Descending by price."""
|
|
322
|
+
asks: list[OrderLevel] = Field(default_factory=list)
|
|
323
|
+
"""Ascending by price."""
|
|
324
|
+
timestamp: int | None = None
|
|
325
|
+
datetime: str | None = None
|
|
326
|
+
book_model: BookModel = "native_per_outcome"
|
|
327
|
+
derived: bool = False
|
|
328
|
+
"""True when these levels were mirrored from the complement's book."""
|
|
329
|
+
depth_scope: Literal["full", "top_n", "unknown"] = "unknown"
|
|
330
|
+
info: dict[str, Any] = Field(default_factory=dict)
|
|
331
|
+
|
|
332
|
+
@computed_field # type: ignore[prop-decorator]
|
|
333
|
+
@property
|
|
334
|
+
def best_bid(self) -> OrderLevel | None:
|
|
335
|
+
"""Highest bid, or `None` on an empty side. Serialized, so an HTTP
|
|
336
|
+
client does not have to know which end of the array is best."""
|
|
337
|
+
return self.bids[0] if self.bids else None
|
|
338
|
+
|
|
339
|
+
@computed_field # type: ignore[prop-decorator]
|
|
340
|
+
@property
|
|
341
|
+
def best_ask(self) -> OrderLevel | None:
|
|
342
|
+
"""Lowest ask, or `None` on an empty side."""
|
|
343
|
+
return self.asks[0] if self.asks else None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class Trade(_Base):
|
|
347
|
+
"""One execution, from the taker's point of view."""
|
|
348
|
+
|
|
349
|
+
id: str
|
|
350
|
+
market_id: str
|
|
351
|
+
"""Synpath id, `venue:native`."""
|
|
352
|
+
timestamp: int
|
|
353
|
+
datetime: str
|
|
354
|
+
price: float
|
|
355
|
+
"""Always the YES price, 0-1. A trade where the taker bought NO at 0.30 is
|
|
356
|
+
reported as `price=0.70, side="sell"`."""
|
|
357
|
+
amount: float
|
|
358
|
+
side: Literal["buy", "sell", "unknown"] = "unknown"
|
|
359
|
+
"""What the taker did on the YES leg: `buy` took YES, `sell` took NO.
|
|
360
|
+
`unknown` when the venue does not say."""
|
|
361
|
+
info: dict[str, Any] = Field(default_factory=dict)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# Historical types extend the live market objects without changing their
|
|
365
|
+
# contract. Queries use recorder receive time; Trade.timestamp remains the
|
|
366
|
+
# venue's execution time when available.
|
|
367
|
+
class HistoryMetadata(_Base):
|
|
368
|
+
dataset_version: str
|
|
369
|
+
time_basis: Literal["recorder_receive"] = "recorder_receive"
|
|
370
|
+
processed_through_ms: int | None = None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class HistoryCoverage(_Base):
|
|
374
|
+
start_ms: int
|
|
375
|
+
end_ms: int
|
|
376
|
+
status: Literal["available", "unavailable"]
|
|
377
|
+
reason: str | None = None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class HistoricalTrade(Trade):
|
|
381
|
+
"""A Trade plus its recorder receive time and timestamp provenance."""
|
|
382
|
+
|
|
383
|
+
observed_at_ms: int
|
|
384
|
+
timestamp_source: Literal["venue", "recorder_fallback"]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class HistoricalOrderBook(OrderBook):
|
|
388
|
+
"""A full book valued at as_of_ms, possibly unchanged since timestamp.
|
|
389
|
+
|
|
390
|
+
Inherited timestamp is the recorder time of the last book update;
|
|
391
|
+
venue_timestamp_ms is the original exchange time when supplied.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
as_of_ms: int
|
|
395
|
+
venue_timestamp_ms: int | None = None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class HistoricalBookChange(_Base):
|
|
399
|
+
"""One change in the requested book view; price is exact and view-relative."""
|
|
400
|
+
kind: Literal["snapshot", "delta"]
|
|
401
|
+
observed_at_ms: int
|
|
402
|
+
venue_timestamp_ms: int | None = None
|
|
403
|
+
book_side: Literal["bid", "ask"] | None = None
|
|
404
|
+
price_exact: str | None = None
|
|
405
|
+
quantity_delta_exact: str | None = None
|
|
406
|
+
book: HistoricalOrderBook | None = None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class HistoricalBookSegment(_Base):
|
|
410
|
+
kind: Literal["data", "absent"]
|
|
411
|
+
start_ms: int
|
|
412
|
+
end_ms: int
|
|
413
|
+
initial_book: HistoricalOrderBook | None = None
|
|
414
|
+
changes: list[HistoricalBookChange] = Field(default_factory=list)
|
|
415
|
+
reason: str | None = None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class OrderBookAtResponse(_Base):
|
|
419
|
+
metadata: HistoryMetadata
|
|
420
|
+
market_id: str
|
|
421
|
+
as_of_ms: int
|
|
422
|
+
book: HistoricalOrderBook | None = None
|
|
423
|
+
absence_reason: str | None = None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class OrderBookRangeResponse(_Base):
|
|
427
|
+
metadata: HistoryMetadata
|
|
428
|
+
market_id: str
|
|
429
|
+
start_ms: int
|
|
430
|
+
end_ms: int
|
|
431
|
+
segments: list[HistoricalBookSegment]
|
|
432
|
+
next_cursor: str | None = None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class TradesRangeResponse(_Base):
|
|
436
|
+
metadata: HistoryMetadata
|
|
437
|
+
market_id: str
|
|
438
|
+
start_ms: int
|
|
439
|
+
end_ms: int
|
|
440
|
+
trades: list[HistoricalTrade]
|
|
441
|
+
coverage: list[HistoryCoverage]
|
|
442
|
+
next_cursor: str | None = None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class Candle(_Base):
|
|
446
|
+
"""One OHLCV bar, labelled with where the prices came from.
|
|
447
|
+
|
|
448
|
+
The label is the point. Kalshi's candlestick endpoint returns a traded-price
|
|
449
|
+
block *and* separate bid/ask blocks, and in a period with no trades the
|
|
450
|
+
traded block is empty — so an OHLC built from it is `None`, while the book
|
|
451
|
+
still has a spread worth reporting. Polymarket publishes no candles at all,
|
|
452
|
+
only sampled price points with no volume. Reporting all three as one
|
|
453
|
+
"OHLCV" would be fiction; `price_source` and a null `volume` say which you
|
|
454
|
+
are holding.
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
timestamp: int
|
|
458
|
+
"""Start of the bar, in ms."""
|
|
459
|
+
datetime: str
|
|
460
|
+
open: float | None = None
|
|
461
|
+
high: float | None = None
|
|
462
|
+
low: float | None = None
|
|
463
|
+
close: float | None = None
|
|
464
|
+
volume: float | None = None
|
|
465
|
+
"""`None` when the venue publishes no volume for the bar, which is not the
|
|
466
|
+
same as zero volume."""
|
|
467
|
+
trade_count: int | None = None
|
|
468
|
+
price_source: PriceSource = "trade"
|
|
469
|
+
"""
|
|
470
|
+
trade — built from executions in the period
|
|
471
|
+
bid_ask_mid — no trades; midpoint of the venue's bid/ask bars
|
|
472
|
+
sampled_mid — the venue published price samples, not bars, and these were
|
|
473
|
+
bucketed by this library (Polymarket)
|
|
474
|
+
"""
|
|
475
|
+
bid_close: float | None = None
|
|
476
|
+
ask_close: float | None = None
|
|
477
|
+
"""Book state at the close of the bar, where the venue reports it."""
|
|
478
|
+
info: dict[str, Any] = Field(default_factory=dict)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
KALSHI_MAKER_SHARE: dict[str, float] = {
|
|
482
|
+
"quadratic": 0.0,
|
|
483
|
+
"quadratic_with_maker_fees": 0.25,
|
|
484
|
+
"quadratic_with_combo_maker_fees": 0.5,
|
|
485
|
+
}
|
|
486
|
+
"""Kalshi's quadratic fee types: the maker fee as a share of the taker
|
|
487
|
+
coefficient (0.07). A plain `quadratic` series charges makers nothing."""
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class FeeSchedule(_Base):
|
|
491
|
+
"""What trading a market costs, before you trade it.
|
|
492
|
+
|
|
493
|
+
Most unified APIs only tell you the fee after a fill. Kalshi publishes it
|
|
494
|
+
per series, and a cross-venue price comparison that ignores it is wrong by
|
|
495
|
+
more than most of the edges people are looking for.
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
venue: str
|
|
499
|
+
scope: Literal["venue", "series", "market"]
|
|
500
|
+
scope_id: str
|
|
501
|
+
fee_type: str
|
|
502
|
+
"""Kalshi: `quadratic` — fee per contract is `multiplier * p * (1 - p)`,
|
|
503
|
+
which peaks at 50c and vanishes at the extremes."""
|
|
504
|
+
multiplier: float | None = None
|
|
505
|
+
maker_rate: float | None = None
|
|
506
|
+
taker_rate: float | None = None
|
|
507
|
+
rounding: str | None = None
|
|
508
|
+
exponent: float | None = None
|
|
509
|
+
"""Polymarket: the power applied to `P * (1 - P)`. Every live market
|
|
510
|
+
publishes `1`; `None` means 1."""
|
|
511
|
+
info: dict[str, Any] = Field(default_factory=dict)
|
|
512
|
+
|
|
513
|
+
def estimate(self, price: float, contracts: float, *, taker: bool = True) -> float | None:
|
|
514
|
+
"""Estimated fee for `contracts` at `price`.
|
|
515
|
+
|
|
516
|
+
Returns `None` when this library does not know the formula behind
|
|
517
|
+
`fee_type`, rather than guessing one. A fee estimate that is quietly
|
|
518
|
+
wrong is worse than no estimate: it turns into a position.
|
|
519
|
+
|
|
520
|
+
Known forms:
|
|
521
|
+
|
|
522
|
+
* Kalshi's quadratic family — a taker pays `0.07 * multiplier * C * P *
|
|
523
|
+
(1 - P)`, largest at 50c and vanishing at the extremes. A maker pays
|
|
524
|
+
nothing on `quadratic`, a quarter of the taker coefficient on
|
|
525
|
+
`quadratic_with_maker_fees` and half on
|
|
526
|
+
`quadratic_with_combo_maker_fees`, as the venue's series
|
|
527
|
+
documentation defines them. With `rounding="up_to_cent"` (every
|
|
528
|
+
Kalshi schedule) the fee for the order is rounded up to the cent, as
|
|
529
|
+
the venue charges it. Kalshi's `flat` type follows a separate table
|
|
530
|
+
this library does not have, so it returns `None`.
|
|
531
|
+
* `quadratic_theta` (Polymarket US, Polymarket) — `theta * C *
|
|
532
|
+
(P * (1 - P)) ** exponent`, the same shape with the coefficient
|
|
533
|
+
published directly: `taker_rate` and `maker_rate` hold the venue's
|
|
534
|
+
thetas, and a negative maker theta is a rebate, returned here as a
|
|
535
|
+
negative fee. `exponent` is 1 unless the venue says otherwise.
|
|
536
|
+
"""
|
|
537
|
+
rate = self.taker_rate if taker else self.maker_rate
|
|
538
|
+
if self.fee_type in KALSHI_MAKER_SHARE and self.multiplier is not None:
|
|
539
|
+
share = 1.0 if taker else KALSHI_MAKER_SHARE[self.fee_type]
|
|
540
|
+
fee = round(0.07 * share * self.multiplier * contracts * price * (1 - price), 9)
|
|
541
|
+
if self.rounding == "up_to_cent":
|
|
542
|
+
fee = math.ceil(fee * 100 - 1e-9) / 100
|
|
543
|
+
return round(fee, 6)
|
|
544
|
+
if self.fee_type == "quadratic_theta" and rate is not None:
|
|
545
|
+
power = 1.0 if self.exponent is None else self.exponent
|
|
546
|
+
return round(rate * contracts * (price * (1 - price)) ** power, 6)
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
class MarketLink(_Base):
|
|
551
|
+
"""The other venue's market for the one a match query was anchored on.
|
|
552
|
+
|
|
553
|
+
No confidence and no settlement verdict: matching here is a deterministic
|
|
554
|
+
parse (both listings resolved to the same canonical proposition), not a
|
|
555
|
+
similarity score, and whether the two actually pay out the same way is a
|
|
556
|
+
disclosure a caller reads dimension by dimension, never a single "same"
|
|
557
|
+
or "not_same" this library hands down. See `match_market`.
|
|
558
|
+
"""
|
|
559
|
+
|
|
560
|
+
id: str
|
|
561
|
+
"""A Synpath id (`polymarket:2252244`) -- the other venue's listing."""
|
|
562
|
+
venue: str
|
|
563
|
+
side_map: dict[BookSide, BookSide]
|
|
564
|
+
"""Which side of *this* link is which side of the anchor: `{"yes": "yes",
|
|
565
|
+
"no": "no"}` when the two agree, `{"yes": "no", "no": "yes"}` when the
|
|
566
|
+
venues put the proposition on opposite sides (Kalshi's "Mashtakov wins?"
|
|
567
|
+
YES is Polymarket's "Pieczonka / Mashtakov" NO)."""
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class MarketMatch(_Base):
|
|
571
|
+
"""The answer to "what is this market on the other venue?" -- see `match_market`."""
|
|
572
|
+
|
|
573
|
+
anchor: str
|
|
574
|
+
"""The Synpath id the query was anchored on."""
|
|
575
|
+
event_id: str | None = None
|
|
576
|
+
"""The canonical event both sides of a match belong to, when the anchor parsed onto one."""
|
|
577
|
+
matched: MarketLink | None = None
|
|
578
|
+
"""`None` means the anchor is a real listing with nothing on the other
|
|
579
|
+
venue asking the same question -- not "unknown", not "no such market"."""
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class EventMatch(_Base):
|
|
583
|
+
"""The answer to "what is this event on the other venues?" -- see `match_event`.
|
|
584
|
+
|
|
585
|
+
A list per venue, not one id: a native event on one venue is often split
|
|
586
|
+
into several on another (Kalshi's "Where will it rain on Sep 23?" is one
|
|
587
|
+
native event and one card per city on the other side).
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
anchor: str
|
|
591
|
+
event_ids: list[str] = Field(default_factory=list)
|
|
592
|
+
"""The canonical event(s) the anchor belongs to. Usually one."""
|
|
593
|
+
events: dict[str, list[str] | None] = Field(default_factory=dict)
|
|
594
|
+
"""Venue name -> its native event ids on the same canonical event(s), or
|
|
595
|
+
`None` when that venue lists nothing there."""
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
class Series(_Base):
|
|
599
|
+
"""A recurring grouping of events above the event tier (Kalshi only today)."""
|
|
600
|
+
|
|
601
|
+
id: str
|
|
602
|
+
venue: str
|
|
603
|
+
title: str | None = None
|
|
604
|
+
category: str | None = None
|
|
605
|
+
tags: list[str] = Field(default_factory=list)
|
|
606
|
+
fee: FeeSchedule | None = None
|
|
607
|
+
settlement_sources: list[dict[str, Any]] = Field(default_factory=list)
|
|
608
|
+
info: dict[str, Any] = Field(default_factory=dict)
|
synpath/ws/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""synpath.ws -- venue WebSockets as typed, self-healing event streams.
|
|
2
|
+
|
|
3
|
+
Every public name here is also exported from the top-level package (the
|
|
4
|
+
base event class as `synpath.StreamEvent`).
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from synpath import KalshiStream, PolymarketMarketStream
|
|
8
|
+
|
|
9
|
+
async with PolymarketMarketStream() as stream:
|
|
10
|
+
await stream.watch_order_book(["<token id>"])
|
|
11
|
+
async for event in stream:
|
|
12
|
+
print(event)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Every stream reconnects on its own, sends its subscriptions again, reports
|
|
16
|
+
gaps it can detect and recovers from them, and marks a reconnect on private
|
|
17
|
+
channels `reconcile_required`. See `synpath.ws.base` for the promises.
|
|
18
|
+
|
|
19
|
+
Part of the base install: `pip install synpath`. The
|
|
20
|
+
Polymarket US exchange API's gRPC streams also need `synpath[grpc]` and the
|
|
21
|
+
venue's protos; see `synpath.ws.grpc`.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from .base import (
|
|
26
|
+
BalanceEvent, BookEvent, BookLevel, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent,
|
|
27
|
+
QuoteEvent, Stream, StreamStats, StreamStatusEvent, TradeEvent, VenueEvent,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __getattr__(name: str):
|
|
32
|
+
if name == "KalshiStream":
|
|
33
|
+
from .kalshi import KalshiStream
|
|
34
|
+
return KalshiStream
|
|
35
|
+
if name in ("PolymarketMarketStream", "PolymarketUserStream"):
|
|
36
|
+
from . import polymarket
|
|
37
|
+
return getattr(polymarket, name)
|
|
38
|
+
if name in ("PolymarketUSMarketStream", "PolymarketUSPrivateStream"):
|
|
39
|
+
from . import polymarket_us
|
|
40
|
+
return getattr(polymarket_us, name)
|
|
41
|
+
if name.startswith("PolymarketUSExchange"):
|
|
42
|
+
from . import polymarket_us_exchange
|
|
43
|
+
return getattr(polymarket_us_exchange, name)
|
|
44
|
+
raise AttributeError(name)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"Stream", "StreamStats", "Event", "BookEvent", "BookLevel", "QuoteEvent", "TradeEvent", "OrderEvent",
|
|
49
|
+
"FillEvent", "PositionEvent", "BalanceEvent", "MarketStatusEvent", "VenueEvent", "StreamStatusEvent",
|
|
50
|
+
"LocalBook", "KalshiStream", "PolymarketMarketStream", "PolymarketUserStream", "PolymarketUSMarketStream",
|
|
51
|
+
"PolymarketUSPrivateStream", "PolymarketUSExchangeOrderStream", "PolymarketUSExchangeDropCopyStream",
|
|
52
|
+
"PolymarketUSExchangeTradeCaptureStream", "PolymarketUSExchangePositionChangeStream",
|
|
53
|
+
"PolymarketUSExchangeInstrumentStream", "PolymarketUSExchangePositionStream",
|
|
54
|
+
"PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream",
|
|
55
|
+
]
|