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.
Files changed (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. 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
+ ]