From 8889b09f9f61ad7c0e7e3d3f286960a7ecfee3d3 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 2 Nov 2026 10:44:00 +0000 Subject: [PATCH 1/2] docs: put attribute explanations above the code they describe --- synpath/__init__.py | 2 +- synpath/_deprecated.py | 2 +- synpath/_native.py | 2 +- synpath/base.py | 121 +++++---- synpath/betfair.py | 46 ++-- synpath/bucket.py | 10 +- synpath/engine/alerts.py | 8 +- synpath/engine/combo_board.py | 24 +- synpath/engine/engine.py | 60 ++--- synpath/engine/events.py | 2 +- synpath/engine/feeds.py | 6 +- synpath/engine/ledger.py | 18 +- synpath/engine/order_log.py | 6 +- synpath/engine/orders/base.py | 2 +- synpath/engine/orders/manager.py | 2 +- synpath/engine/orders/oco.py | 2 +- synpath/engine/orders/routed.py | 31 ++- synpath/engine/orders/stop.py | 4 +- synpath/engine/orders/twap.py | 4 +- synpath/engine/paper.py | 42 ++-- synpath/engine/rfq.py | 24 +- synpath/engine/rfq_group.py | 38 +-- synpath/engine/risk.py | 22 +- synpath/engine/router.py | 24 +- synpath/engine/storage_types.py | 10 +- synpath/errors.py | 2 +- synpath/hyperliquid.py | 38 +-- synpath/ids.py | 6 +- synpath/kalshi.py | 109 ++++---- synpath/limitless.py | 22 +- synpath/matching.py | 4 +- synpath/opinion.py | 50 ++-- synpath/polymarket.py | 46 ++-- synpath/polymarket_us.py | 34 +-- synpath/predict_fun.py | 22 +- synpath/server/api.py | 29 ++- synpath/server/batch.py | 6 +- synpath/server/bucket_books.py | 14 +- synpath/server/combo_warmer.py | 12 +- synpath/server/market_stream.py | 14 +- synpath/server/rfq_routes.py | 10 +- synpath/server/serve.py | 16 +- synpath/server/trading.py | 24 +- synpath/smarkets.py | 40 +-- synpath/sx_bet.py | 28 +-- synpath/trading/credentials.py | 142 +++++------ synpath/trading/hyperliquid.py | 16 +- synpath/trading/hyperliquid_signing.py | 2 +- synpath/trading/init.py | 4 +- synpath/trading/instruments.py | 16 +- synpath/trading/kalshi.py | 38 +-- synpath/trading/limiter.py | 8 +- synpath/trading/limitless.py | 10 +- synpath/trading/limitless_signing.py | 2 +- synpath/trading/money.py | 4 +- synpath/trading/opinion.py | 28 +-- synpath/trading/opinion_signing.py | 2 +- synpath/trading/polymarket.py | 28 +-- synpath/trading/polymarket_rfq.py | 38 +-- synpath/trading/polymarket_signing.py | 16 +- synpath/trading/polymarket_us.py | 36 +-- synpath/trading/polymarket_us_exchange.py | 4 +- synpath/trading/polymarket_v3_signing.py | 6 +- synpath/trading/predict_fun.py | 26 +- synpath/trading/predict_fun_signing.py | 8 +- synpath/trading/sx_bet.py | 42 ++-- synpath/trading/sx_bet_signing.py | 4 +- synpath/trading/types.py | 292 +++++++++++----------- synpath/types.py | 230 +++++++++-------- tests/serve_harness.py | 12 +- tests/test_combo_warmer.py | 2 +- tests/test_hyperliquid_trading.py | 2 +- tests/test_server.py | 2 +- 73 files changed, 1022 insertions(+), 1036 deletions(-) diff --git a/synpath/__init__.py b/synpath/__init__.py index 0317e45..40fa158 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -84,6 +84,7 @@ __version__ = "0.8.1" +# Every venue this library speaks, by id. exchanges: dict[str, type[Exchange]] = { "kalshi": Kalshi, "polymarket": Polymarket, @@ -96,7 +97,6 @@ "betfair": Betfair, "smarkets": Smarkets, } -"""Every venue this library speaks, by id.""" def exchange(venue: str, **kwargs) -> Exchange: diff --git a/synpath/_deprecated.py b/synpath/_deprecated.py index 428470f..627cae0 100644 --- a/synpath/_deprecated.py +++ b/synpath/_deprecated.py @@ -23,8 +23,8 @@ T = TypeVar("T") +# The rename every contract-count field and argument went through. QUANTITY = {"amount": "quantity"} -"""The rename every contract-count field and argument went through.""" def warn(old: str, new: str, *, stacklevel: int = 3) -> None: diff --git a/synpath/_native.py b/synpath/_native.py index 11c3424..3a0f7a6 100644 --- a/synpath/_native.py +++ b/synpath/_native.py @@ -23,8 +23,8 @@ except ImportError: # pragma: no cover - only without a built extension core = None +# True when the Rust core is in use. ENABLED = core is not None -"""True when the Rust core is in use.""" def pick(name: str, fallback: Any) -> Any: diff --git a/synpath/base.py b/synpath/base.py index b12eba0..0000582 100644 --- a/synpath/base.py +++ b/synpath/base.py @@ -34,15 +34,24 @@ from . import ids from .types import BookSide, Candle, Event, FeeSchedule, Market, OrderBook, Series, Trade +# What a venue can do, in the shape ccxt's `has` uses. +# +# True — supported; the method's docstring says how the venue answers it +# "partial" — supported, but the result is thinner than the type suggests; +# the method's docstring says exactly how +# False — not available; calling raises `NotSupported` Capability = Literal[True, False, "partial"] -"""What a venue can do, in the shape ccxt's `has` uses. - - True — supported; the method's docstring says how the venue answers it - "partial" — supported, but the result is thinner than the type suggests; - the method's docstring says exactly how - False — not available; calling raises `NotSupported` -""" +# Every question `has` can be asked, for every venue. +# +# This list is the single place a capability is named. `Exchange.__init_subclass__` +# fills a venue's `has` from it, so a venue that says nothing about a capability +# gets `False` rather than a hole. `has[key]` therefore never raises KeyError on +# any venue — which matters, because the whole point of `has` is to be safe to +# read *before* you know whether something is supported. +# +# The read-only adapters answer `False` to every trading key; a trading +# adapter for the same venue subclasses one and says what it adds. CAPABILITIES: tuple[str, ...] = ( # -- read -- "fetch_markets", @@ -94,64 +103,49 @@ "watch_positions", "watch_balance", ) -"""Every question `has` can be asked, for every venue. - -This list is the single place a capability is named. `Exchange.__init_subclass__` -fills a venue's `has` from it, so a venue that says nothing about a capability -gets `False` rather than a hole. `has[key]` therefore never raises KeyError on -any venue — which matters, because the whole point of `has` is to be safe to -read *before* you know whether something is supported. - -The read-only adapters answer `False` to every trading key; a trading -adapter for the same venue subclasses one and says what it adds. -""" LEGAL_CAPABILITY_VALUES = frozenset({True, False, "partial"}) +# The status words every venue understands, with one meaning each. +# +# open — listed and trading +# closed — trading stopped, outcome not yet final +# settled — outcome final and paid +# all — no filter +# +# A venue that cannot filter by one of these raises `NotSupported` for it. What +# it must not do is accept the word and quietly answer a different question: +# `status="settled"` returning live markets on one venue and settled ones on +# another is the kind of disagreement a unified API exists to remove. MARKET_STATUSES: tuple[str, ...] = ("open", "closed", "settled", "all") -"""The status words every venue understands, with one meaning each. - - open — listed and trading - closed — trading stopped, outcome not yet final - settled — outcome final and paid - all — no filter - -A venue that cannot filter by one of these raises `NotSupported` for it. What -it must not do is accept the word and quietly answer a different question: -`status="settled"` returning live markets on one venue and settled ones on -another is the kind of disagreement a unified API exists to remove. -""" +# How a page of markets can be ordered. +# +# Polymarket orders at the venue. Kalshi and Polymarket US accept a sort +# parameter and ignore it, so on those the page is ordered here after it is +# read, the way ccxt and pmxt do it: `sort` then means "this page, ordered by", +# not "the first page of the whole catalog ordered by". Each adapter's +# `fetch_markets` docstring says which venue figure the key reads. MARKET_SORTS: tuple[str, ...] = ("volume", "liquidity", "newest") -"""How a page of markets can be ordered. - -Polymarket orders at the venue. Kalshi and Polymarket US accept a sort -parameter and ignore it, so on those the page is ordered here after it is -read, the way ccxt and pmxt do it: `sort` then means "this page, ordered by", -not "the first page of the whole catalog ordered by". Each adapter's -`fetch_markets` docstring says which venue figure the key reads. -""" +# Rows one catalog call can return, on every venue. +# +# Uniform rather than per-venue. Polymarket's keyset endpoint hard-caps at 100 no +# matter what is asked, so a higher ceiling elsewhere would mean `limit=500` +# returning 500 rows on one venue and 100 on another, with nothing saying it had +# been reduced. Ask for more than this and it is clamped; the cursor is how you +# get the rest. MAX_PAGE_LIMIT = 100 -"""Rows one catalog call can return, on every venue. - -Uniform rather than per-venue. Polymarket's keyset endpoint hard-caps at 100 no -matter what is asked, so a higher ceiling elsewhere would mean `limit=500` -returning 500 rows on one venue and 100 on another, with nothing saying it had -been reduced. Ask for more than this and it is clamped; the cursor is how you -get the rest. -""" +# Longest this library will sleep inside one call, in seconds. +# +# A venue's `Retry-After` is a hint, not an instruction. Honouring a large one +# literally would let the venue park a caller's thread for as long as it likes -- +# and in the server that thread belongs to a pool shared with every other +# request, so a handful of rate-limited calls would take the whole service down. +# The header's real value is still on the raised `RateLimitExceeded`, so a caller +# that wants to wait longer can decide that for itself. MAX_RETRY_WAIT = 10.0 -"""Longest this library will sleep inside one call, in seconds. - -A venue's `Retry-After` is a hint, not an instruction. Honouring a large one -literally would let the venue park a caller's thread for as long as it likes -- -and in the server that thread belongs to a pool shared with every other -request, so a handful of rate-limited calls would take the whole service down. -The header's real value is still on the raised `RateLimitExceeded`, so a caller -that wants to wait longer can decide that for itself. -""" class RateLimiter: @@ -282,9 +276,9 @@ def __init__( self.base_url = base_url.rstrip("/") self.limiter = limiter self.attempts = attempts + # Named at the front of every error this client raises, so a caller + # reads `kalshi: insufficient balance`, not a class name and a path. self.venue = venue - """Named at the front of every error this client raises, so a caller - reads `kalshi: insufficient balance`, not a class name and a path.""" self._client = client or httpx.Client(timeout=timeout, follow_redirects=True) def request(self, method: str, path: str, **kwargs: Any) -> Any: @@ -355,9 +349,9 @@ def __init__( self.base_url = base_url.rstrip("/") self.limiter = limiter self.attempts = attempts + # Named at the front of every error this client raises, so a caller + # reads `kalshi: insufficient balance`, not a class name and a path. self.venue = venue - """Named at the front of every error this client raises, so a caller - reads `kalshi: insufficient balance`, not a class name and a path.""" self._client = client or httpx.AsyncClient( timeout=timeout, follow_redirects=True, headers=headers, ) @@ -534,12 +528,11 @@ class Exchange(ABC): id: str name: str + # What this venue can do. Always complete: see `__init_subclass__`. + # + # A subclass declares only what differs from "no". Everything in + # `CAPABILITIES` it stays silent about is filled in as `False`. has: dict[str, Capability] = {} - """What this venue can do. Always complete: see `__init_subclass__`. - - A subclass declares only what differs from "no". Everything in - `CAPABILITIES` it stays silent about is filled in as `False`. - """ face_value: float = 1.0 book_model: str = "native_per_outcome" diff --git a/synpath/betfair.py b/synpath/betfair.py index e6b97ba..f6ac83e 100644 --- a/synpath/betfair.py +++ b/synpath/betfair.py @@ -76,6 +76,8 @@ VENUE = "betfair" +# Where an account logs in and calls the API, by the Betfair site it is +# registered on. An Italian account cannot log in on .com, and so on. JURISDICTIONS: dict[str, dict[str, str]] = { "com": {"identity": "identitysso.betfair.com", "cert": "identitysso-cert.betfair.com", "api": "api.betfair.com"}, "it": {"identity": "identitysso.betfair.it", "cert": "identitysso-cert.betfair.it", "api": "api.betfair.it"}, @@ -86,67 +88,65 @@ "com.au": {"identity": "identitysso.betfair.com.au", "cert": "identitysso-cert.betfair.com.au", "api": "api.betfair.com"}, } -"""Where an account logs in and calls the API, by the Betfair site it is -registered on. An Italian account cannot log in on .com, and so on.""" BETTING_PATH = "/exchange/betting/rest/v1.0" +# Betfair publishes no request rate for market data, only a weight per +# request; kept modest and shared process-wide. LIMITER = RateLimiter(5.0, burst=10) -"""Betfair publishes no request rate for market data, only a weight per -request; kept modest and shared process-wide.""" +# Seconds after which the session is extended with `keepAlive` before the +# next call. A session lasts 12 hours (24 on UK and Irish accounts) from its +# last keepAlive, and API calls do not extend it, so a long-running process +# has to. SESSION_REFRESH_AFTER = 4 * 3600.0 -"""Seconds after which the session is extended with `keepAlive` before the -next call. A session lasts 12 hours (24 on UK and Irish accounts) from its -last keepAlive, and API calls do not extend it, so a long-running process -has to.""" +# A request's projection weight times its number of markets may not pass +# this, or Betfair refuses the whole request with `TOO_MUCH_DATA`. MAX_WEIGHT = 200 -"""A request's projection weight times its number of markets may not pass -this, or Betfair refuses the whole request with `TOO_MUCH_DATA`.""" CATALOGUE_WEIGHTS: dict[str, int] = { "COMPETITION": 0, "EVENT": 0, "EVENT_TYPE": 0, "MARKET_START_TIME": 0, "RUNNER_DESCRIPTION": 0, "MARKET_DESCRIPTION": 1, "RUNNER_METADATA": 1, } BOOK_WEIGHTS: dict[str, int] = {"EX_BEST_OFFERS": 5, "EX_TRADED": 17, "EX_ALL_OFFERS": 17} +# Betfair's weights for the pairs it prices below their sum. BOOK_COMBINED_WEIGHTS: dict[frozenset[str], int] = { frozenset({"EX_BEST_OFFERS", "EX_TRADED"}): 20, frozenset({"EX_ALL_OFFERS", "EX_TRADED"}): 32, } -"""Betfair's weights for the pairs it prices below their sum.""" +# `listMarketBook` with no price projection still weighs 2 a market. BOOK_BASE_WEIGHT = 2 -"""`listMarketBook` with no price projection still weighs 2 a market.""" LISTING_PROJECTION = ("EVENT", "EVENT_TYPE", "COMPETITION", "MARKET_START_TIME", "MARKET_DESCRIPTION", "RUNNER_DESCRIPTION") LOOKUP_PROJECTION = LISTING_PROJECTION + ("RUNNER_METADATA",) +# The only price projection a delayed key serves in full: three levels a side. BOOK_PRICE_DATA = ("EX_BEST_OFFERS",) -"""The only price projection a delayed key serves in full: three levels a side.""" +# `listMarketCatalogue` returns at most this many markets, weight permitting. MAX_CATALOGUE_RESULTS = 1000 -"""`listMarketCatalogue` returns at most this many markets, weight permitting.""" +# Most catalogue requests one listing call makes while filling a page. MAX_PAGES = 50 -"""Most catalogue requests one listing call makes while filling a page.""" +# The Market Base Rate on most .com markets, used when a market's own +# `marketBaseRate` was not read. DEFAULT_COMMISSION = 0.05 -"""The Market Base Rate on most .com markets, used when a market's own -`marketBaseRate` was not read.""" +# Betfair sorts its catalogue by matched or available money; it has no +# listing time to sort by. SORTS = {"volume": "MAXIMUM_TRADED", "liquidity": "MAXIMUM_AVAILABLE"} -"""Betfair sorts its catalogue by matched or available money; it has no -listing time to sort by.""" SESSION_ERRORS = frozenset({"INVALID_SESSION_INFORMATION", "NO_SESSION"}) AUTH_ERRORS = SESSION_ERRORS | {"INVALID_APP_KEY", "NO_APP_KEY", "ACCESS_DENIED", "APP_KEY_CREATION_FAILED"} RATE_ERRORS = frozenset({"TOO_MANY_REQUESTS"}) +# Betfair's CLASSIC odds ladder: from each band's start to its end, in its step. CLASSIC_LADDER_BANDS: tuple[tuple[str, str, str], ...] = ( ("1.01", "2", "0.01"), ("2", "3", "0.02"), ("3", "4", "0.05"), ("4", "6", "0.1"), ("6", "10", "0.2"), ("10", "20", "0.5"), ("20", "30", "1"), ("30", "50", "2"), ("50", "100", "5"), ("100", "1000", "10"), ) -"""Betfair's CLASSIC odds ladder: from each band's start to its end, in its step.""" def _classic_ladder() -> tuple[Decimal, ...]: @@ -160,8 +160,8 @@ def _classic_ladder() -> tuple[Decimal, ...]: return tuple(ticks) +# Every price Betfair accepts on a CLASSIC market, 1.01 to 1000 (350 ticks). CLASSIC_LADDER: tuple[Decimal, ...] = _classic_ladder() -"""Every price Betfair accepts on a CLASSIC market, 1.01 to 1000 (350 ticks).""" _LADDER_SET = frozenset(CLASSIC_LADDER) @@ -247,9 +247,9 @@ def raw_levels(rows: Any) -> list[list[float]]: return [[row["price"], row["size"]] for row in rows or [] if row and row.get("price") is not None] +# A runner's final status as the YES result. A removed runner (a non-runner) +# has its bets voided. PLACED is the place leg of an each-way market. RESULTS: dict[str, str] = {"WINNER": "yes", "LOSER": "no", "PLACED": "yes", "REMOVED": "void"} -"""A runner's final status as the YES result. A removed runner (a non-runner) -has its bets voided. PLACED is the place leg of an each-way market.""" def status_of(market_status: str | None, runner_status: str | None) -> tuple[str, bool]: diff --git a/synpath/bucket.py b/synpath/bucket.py index 10c7e6d..20d921d 100644 --- a/synpath/bucket.py +++ b/synpath/bucket.py @@ -30,14 +30,14 @@ class BucketMember(_Base): + # A Synpath id, `venue:native`. market_id: str - """A Synpath id, `venue:native`.""" + # True when this member's YES is the opposite of the bucket's proposition. flip: bool = False - """True when this member's YES is the opposite of the bucket's proposition.""" + # False switches the member off: it stays in the definition but is not + # routed to, not in the merged bucket book, and not streamed for it. + # Definitions stored before this field existed load with every member on. enabled: bool = True - """False switches the member off: it stays in the definition but is not - routed to, not in the merged bucket book, and not streamed for it. - Definitions stored before this field existed load with every member on.""" def book_side(self) -> BookSide: """Which of the member's two books is the bucket's YES book. Reading diff --git a/synpath/engine/alerts.py b/synpath/engine/alerts.py index 3da5e0d..5db3c8d 100644 --- a/synpath/engine/alerts.py +++ b/synpath/engine/alerts.py @@ -59,15 +59,15 @@ def __str__(self) -> str: class AlertRules: """When to speak up.""" + # Venue rejections within the window before one alert is raised. reject_burst: int = 10 - """Venue rejections within the window before one alert is raised.""" reject_window_s: float = 60.0 + # Fraction of the daily loss limit that warrants a warning. daily_loss_warning: Decimal = Decimal("0.8") - """Fraction of the daily loss limit that warrants a warning.""" + # The same alert is not repeated inside this window. repeat_after_s: float = 300.0 - """The same alert is not repeated inside this window.""" + # Alert kinds to suppress entirely. quiet_kinds: tuple[str, ...] = () - """Alert kinds to suppress entirely.""" class Alerts: diff --git a/synpath/engine/combo_board.py b/synpath/engine/combo_board.py index 096fe06..59d3dfe 100644 --- a/synpath/engine/combo_board.py +++ b/synpath/engine/combo_board.py @@ -72,9 +72,9 @@ LABELS = {key: label for key, label, _ in LEAGUES} OTHER = ("other", "Other") +# A question event (a price bracket, a field of candidates) shows at most this +# many markets: those priced nearest even, where a choice is live. QUESTIONS_PER_EVENT = 3 -"""A question event (a price bracket, a field of candidates) shows at most this -many markets: those priced nearest even, where a choice is live.""" @@ -716,8 +716,8 @@ def millis(value: Any) -> int | None: # Leg labels: a short name for each market on a board # --------------------------------------------------------------------------- +# A label longer than this is cut, with an ellipsis. LABEL_LENGTH = 60 -"""A label longer than this is cut, with an ellipsis.""" def market_labels(board: ComboBoard) -> dict[str, str]: @@ -867,24 +867,24 @@ def page_offset(cursor: str | None) -> int: # Reading a venue # --------------------------------------------------------------------------- +# Pages of a venue's eligible list (or of Kalshi's collections) a board read +# on request reads. serve's warmer (`synpath.server.combo_warmer`) reads every +# page (`pages=None`). BOARD_PAGES = 5 -"""Pages of a venue's eligible list (or of Kalshi's collections) a board read -on request reads. serve's warmer (`synpath.server.combo_warmer`) reads every -page (`pages=None`).""" +# Kalshi: the most collection events whose markets a board read on request +# reads (`max_events=None`: every one). BOARD_EVENTS = 150 -"""Kalshi: the most collection events whose markets a board read on request -reads (`max_events=None`: every one).""" +# Gamma's largest batch. PRICE_BATCH = 100 -"""Gamma's largest batch.""" KALSHI_EVENT_BATCH = 100 EVENT_BATCH = 20 +# A whole-list build with `progress` publishes its board after this many +# pages (the venue lists by volume, so the busiest markets come first)... FIRST_PUBLISH_PAGES = 5 -"""A whole-list build with `progress` publishes its board after this many -pages (the venue lists by volume, so the busiest markets come first)...""" +# ...and again every this many pages, until the list ends. PUBLISH_EVERY_PAGES = 40 -"""...and again every this many pages, until the list ends.""" class BoardCache: diff --git a/synpath/engine/engine.py b/synpath/engine/engine.py index d17d128..857f0c2 100644 --- a/synpath/engine/engine.py +++ b/synpath/engine/engine.py @@ -82,36 +82,36 @@ class EngineConfig: lease_name: str = "engine" lease_ttl_ms: int = 30_000 lease_renew_s: float = 10.0 + # How long an unanswered send stays in doubt before the sweep resolves it. in_doubt_timeout_s: float = 20.0 - """How long an unanswered send stays in doubt before the sweep resolves it.""" + # How long the venue must keep saying it has no such order before the + # engine believes it. Venues do not list an order the instant they accept + # it -- Kalshi's demo takes a few hundred milliseconds -- so declaring one + # lost too early would abandon a live order. lost_after_s: float = 60.0 - """How long the venue must keep saying it has no such order before the - engine believes it. Venues do not list an order the instant they accept - it -- Kalshi's demo takes a few hundred milliseconds -- so declaring one - lost too early would abandon a live order.""" sweep_interval_s: float = 10.0 reconcile_interval_s: float = 60.0 + # How often to poll orders and fills when no stream is attached. poll_interval_s: float = 5.0 - """How often to poll orders and fills when no stream is attached.""" halt_policy: HaltPolicy = "cancel" require_lease: bool = True + # How often engine-held orders get their timer. A TWAP slice, an + # iceberg reload and a peg's minimum stay are all measured against it. managed_tick_s: float = 1.0 - """How often engine-held orders get their timer. A TWAP slice, an - iceberg reload and a peg's minimum stay are all measured against it.""" + # Where "today" ends, for Day orders. Europe/London by default, including seasonal clock changes. session_timezone: str | None = "Europe/London" - """Where "today" ends, for Day orders. Europe/London by default, including seasonal clock changes.""" session_end: str = "23:59:59" + # How often the RFQ follower reads accepted and confirmed quotes, the + # quotes on our open RFQs and our own maker quotes (and expires them). rfq_poll_interval_s: float = 1.0 - """How often the RFQ follower reads accepted and confirmed quotes, the - quotes on our open RFQs and our own maker quotes (and expires them).""" + # How often to poll incoming RFQs on quoting venues without a stream + # (`rfq.incoming`); venues that push (`subscribe_incoming_rfqs`) are not + # polled. `None` turns the feed off, streams included. rfq_incoming_interval_s: float | None = 2.0 - """How often to poll incoming RFQs on quoting venues without a stream - (`rfq.incoming`); venues that push (`subscribe_incoming_rfqs`) are not - polled. `None` turns the feed off, streams included.""" + # Start the venues' incoming-RFQ streams (Polymarket global's quoter + # session, SX Bet's parlay channel) when the RFQ loop starts. `False` + # leaves starting them to the caller; RFQs they push still reach the engine. rfq_stream_incoming: bool = True - """Start the venues' incoming-RFQ streams (Polymarket global's quoter - session, SX Bet's parlay channel) when the RFQ loop starts. `False` - leaves starting them to the caller; RFQs they push still reach the engine.""" @dataclass(slots=True) @@ -119,17 +119,17 @@ class Recovery: """What `recover()` found. Every number here is a thing that was in doubt.""" in_doubt: int = 0 + # Intents whose order was found at the venue after all. adopted: int = 0 - """Intents whose order was found at the venue after all.""" + # Intents with no order at the venue: nothing was sent. swept: int = 0 - """Intents with no order at the venue: nothing was sent.""" + # Still in doubt: the venue could not be reached. Trading stays blocked + # for these instruments until it can. unresolved: int = 0 - """Still in doubt: the venue could not be reached. Trading stays blocked - for these instruments until it can.""" fills_replayed: int = 0 orders_open: int = 0 + # Engine-held orders brought back and still running. managed: int = 0 - """Engine-held orders brought back and still running.""" details: list[dict[str, Any]] = field(default_factory=list) def summary(self, *, detail_limit: int = 20) -> dict[str, Any]: @@ -140,9 +140,9 @@ def summary(self, *, detail_limit: int = 20) -> dict[str, Any]: } +# Engine types whose children rest on the book, where `post_only` means +# something. A TWAP only in its `limit` style. POST_ONLY_KINDS = ("iceberg", "peg", "twap", "stop_limit") -"""Engine types whose children rest on the book, where `post_only` means -something. A TWAP only in its `limit` style.""" STOP_KINDS = frozenset({"stop_market", "stop_limit", "trailing_stop"}) @@ -222,23 +222,23 @@ def __init__( self.dispatch_running = False self.dispatch_parent: str | None = None self._locks: dict[str, asyncio.Lock] = {} + # Resolution timestamps, for the closing-soon guard; filled by the caller. self.market_close: dict[str, int] = {} - """Resolution timestamps, for the closing-soon guard; filled by the caller.""" + # Market to event id, for the per-event cap. self.event_of: dict[str, str] = {} - """Market to event id, for the per-event cap.""" self._open_cache: dict[str, Order] = {} # Creates, restores and advances engine-held orders, and links their venue children. self.managed_order_controller = ManagedOrderController(self) + # The local books engine-held orders watch, fed by `on_book`. self.books: dict[str, Any] = {} - """The local books engine-held orders watch, fed by `on_book`.""" self.last_trade: dict[str, Decimal] = {} self.last_trade_timestamps: dict[str, int] = {} self.market_master = MarketMaster() + # RFQs and combos, both sides (docs/rfq-combo.md); the methods below delegate to it. self.rfq = RfqDesk(self) - """RFQs and combos, both sides (docs/rfq-combo.md); the methods below delegate to it.""" + # Cross-venue RFQ groups over the desk (docs/rfq-combo.md, "Cross-venue RFQ"). `matcher` + # maps a market onto another venue; by default the hosted matching service. self.rfq_groups = RfqGroupDesk(self, matcher=matcher) - """Cross-venue RFQ groups over the desk (docs/rfq-combo.md, "Cross-venue RFQ"). `matcher` - maps a market onto another venue; by default the hosted matching service.""" # -- lifecycle ------------------------------------------------------------ diff --git a/synpath/engine/events.py b/synpath/engine/events.py index 4ba39e8..9bc71a5 100644 --- a/synpath/engine/events.py +++ b/synpath/engine/events.py @@ -37,8 +37,8 @@ class EngineEvent: payload: dict[str, Any] = field(default_factory=dict) key: str | None = None ts: int = field(default_factory=lambda: int(time.time() * 1000)) + # The journal sequence number, once it has one. seq: int | None = None - """The journal sequence number, once it has one.""" def __str__(self) -> str: return f"{self.kind}({self.key})" if self.key else self.kind diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index 1761520..fd5b501 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -202,10 +202,10 @@ def __init__(self, engine: Any, streams: Mapping[str, VenueStreams], *, sync_int self.watching: dict[str, set[str]] = {venue: set() for venue in self.streams} self.dispatched = 0 self.closing = False + # Called with `(venue, stream, event)` for every event, after the + # engine has it: how the server's market socket shares these + # connections instead of opening its own. A listener must not block. self.listeners: list[Callable[[str, Any, Any], None]] = [] - """Called with `(venue, stream, event)` for every event, after the - engine has it: how the server's market socket shares these - connections instead of opening its own. A listener must not block.""" def distinct(self) -> list[tuple[str, Any]]: """Every stream object once, with the venue it belongs to.""" diff --git a/synpath/engine/ledger.py b/synpath/engine/ledger.py index 55c2c0e..288cf66 100644 --- a/synpath/engine/ledger.py +++ b/synpath/engine/ledger.py @@ -51,19 +51,19 @@ class PositionState: book: str market_id: str venue: str + # Signed: positive is long YES, negative is short YES (long NO). contracts: Decimal = ZERO - """Signed: positive is long YES, negative is short YES (long NO).""" + # Per contract, of what is currently open; for a short, what was received. average_cost: Decimal = ZERO - """Per contract, of what is currently open; for a short, what was received.""" realized: Decimal = ZERO fees: Decimal = ZERO bought: Decimal = ZERO sold: Decimal = ZERO + # Contracts traded, both directions. volume: Decimal = ZERO - """Contracts traded, both directions.""" + # Contracts held per side (`"yes"`, `"no"`) before netting, as the + # engine's own fills imply: a sell adds to `no`. inventory: dict[str, Decimal] = field(default_factory=dict) - """Contracts held per side (`"yes"`, `"no"`) before netting, as the - engine's own fills imply: a sell adds to `no`.""" resolved: bool = False last_ts: int | None = None @@ -167,18 +167,18 @@ class Rollup: level: Level key: str + # Net signed contracts; for a roll-up above one market it is the sum, + # which nets long one market against short another only if they are the + # same market. contracts: Decimal = ZERO - """Net signed contracts; for a roll-up above one market it is the sum, - which nets long one market against short another only if they are the - same market.""" cost: Decimal = ZERO realized: Decimal = ZERO unrealized: Decimal | None = None fees: Decimal = ZERO volume: Decimal = ZERO positions: int = 0 + # How many of the open positions had a mark; the rest are cost only. marked: int = 0 - """How many of the open positions had a mark; the rest are cost only.""" @property def total(self) -> Decimal: diff --git a/synpath/engine/order_log.py b/synpath/engine/order_log.py index c25a0cd..132926f 100644 --- a/synpath/engine/order_log.py +++ b/synpath/engine/order_log.py @@ -32,14 +32,14 @@ ORDER_EVENT_PREFIXES = ("intent.", "submission.", "order.", "managed.", "fill.") ORDER_EVENT_KINDS = ("risk.rejected",) +# Engine-wide, not about one order: the journal view shows them. NOT_ORDER_KINDS = frozenset({"managed.restored"}) -"""Engine-wide, not about one order: the journal view shows them.""" RAW_BATCH = 1_000 +# Raw events one page reads at most, however many fold away. MOST_RAW_EVENTS = 50_000 -"""Raw events one page reads at most, however many fold away.""" +# An order's fields a later event of it may leave out. CARRIED = ("market_id", "venue", "side", "outcome", "action") -"""An order's fields a later event of it may leave out.""" ORDER_FIELDS = ("market_id", "side", "outcome", "action", "type", "quantity", "filled", "price", "stop_price") diff --git a/synpath/engine/orders/base.py b/synpath/engine/orders/base.py index f314b9b..be8533e 100644 --- a/synpath/engine/orders/base.py +++ b/synpath/engine/orders/base.py @@ -247,8 +247,8 @@ def __init__( self.updated_ms = self.created_ms self.detail: str = "" self.params: dict[str, Any] = dict(request.params or {}) + # Set when this parent is a leg of another one (a bracket's stop). self.owner_id: str | None = None - """Set when this parent is a leg of another one (a bracket's stop).""" # -- identity ------------------------------------------------------------- diff --git a/synpath/engine/orders/manager.py b/synpath/engine/orders/manager.py index 5ce5a91..776577b 100644 --- a/synpath/engine/orders/manager.py +++ b/synpath/engine/orders/manager.py @@ -47,8 +47,8 @@ class ManagedOrderController: def __init__(self, engine: Any): self.engine = engine self.parents: dict[str, ManagedOrder] = {} + # Child order id to parent id. self.by_child: dict[str, str] = {} - """Child order id to parent id.""" self.by_market: dict[str, set[str]] = {} self._running_tasks: dict[str, Any] = {} diff --git a/synpath/engine/orders/oco.py b/synpath/engine/orders/oco.py index 9d4bd8c..c4f9c79 100644 --- a/synpath/engine/orders/oco.py +++ b/synpath/engine/orders/oco.py @@ -172,8 +172,8 @@ def __init__(self, *args: Any, **kwargs: Any): self.stop_loss = dict(self.params.get("stop_loss") or {}) if not self.take_profit and not self.stop_loss: raise ValueError("a bracket needs params take_profit, stop_loss, or both") + # Contracts the entry has filled, which is what the protection covers. self.entered: Decimal = ZERO - """Contracts the entry has filled, which is what the protection covers.""" self.protected: Decimal = ZERO def markets(self) -> list[str]: diff --git a/synpath/engine/orders/routed.py b/synpath/engine/orders/routed.py index c67598a..690873a 100644 --- a/synpath/engine/orders/routed.py +++ b/synpath/engine/orders/routed.py @@ -36,17 +36,17 @@ from .base import ZERO, Child, Context, D, ManagedOrder, State, now_ms from .manager import register +# Larger reference orders asked for while a venue's floor hides its curve: +# 100, then 10,000, a million and 100 million contracts. FEE_REFERENCE_TRIES = 4 -"""Larger reference orders asked for while a venue's floor hides its curve: -100, then 10,000, a million and 100 million contracts.""" +# Contracts in the order a level's fee is quoted for, then divided back to +# one contract. A one-contract quote overstates the rate wherever a venue +# charges per order: Kalshi rounds each order's fee up to the cent, and +# Opinion charges at least 0.25 USDT an order, which would read as 25c a +# contract and keep the router off the venue entirely. The floor itself is +# kept per member and applied to whole legs by the plan. FEE_REFERENCE = Decimal("100") -"""Contracts in the order a level's fee is quoted for, then divided back to -one contract. A one-contract quote overstates the rate wherever a venue -charges per order: Kalshi rounds each order's fee up to the cent, and -Opinion charges at least 0.25 USDT an order, which would read as 25c a -contract and keep the router off the venue entirely. The floor itself is -kept per member and applied to whole legs by the plan.""" @dataclass @@ -56,8 +56,8 @@ class LegState: order_id: str venue: str side: str + # The member's own YES price, as sent. price: Decimal - """The member's own YES price, as sent.""" bucket_price: Decimal quantity: Decimal placed_at: float @@ -105,22 +105,21 @@ def __init__(self, *args: Any, **kwargs: Any): self.rounds = 0 self.started_at = 0.0 self.stop_reason = "" + # The incomplete order's intended ending while child cancellations are pending. self.pending_final_state: State = "canceled" - """The incomplete order's intended ending while child cancellations are pending.""" self.fees_paid = ZERO self.fee_cache: dict[str, Decimal] = {} + # Per member, the least one taker order pays, as its venue reported. self.fee_floors: dict[str, Decimal] = {} - """Per member, the least one taker order pays, as its venue reported.""" """`market_id@price` -> taker fee per contract.""" self.fee_unknown: set[str] = set() self._replanning = False self._replan_again = False + # Whether refresh of restored children has been attempted in this process. + # New parents set this in start() because they have no restored children. + # resync() sets it before reading venues; failed reads are logged and + # skipped, so True does not prove every child's status is current. self._resynced = False - """Whether refresh of restored children has been attempted in this process. - New parents set this in start() because they have no restored children. - resync() sets it before reading venues; failed reads are logged and - skipped, so True does not prove every child's status is current. - """ # -- identity: the bucket, and every member --------------------------------- diff --git a/synpath/engine/orders/stop.py b/synpath/engine/orders/stop.py index 7b77f5b..47f845a 100644 --- a/synpath/engine/orders/stop.py +++ b/synpath/engine/orders/stop.py @@ -66,8 +66,8 @@ TRIGGER_SOURCES = tuple(source.value for source in TriggerSource) +# Legacy trailing-stop protection; stop-market uses legal venue bounds. TRAILING_PROTECTION = Decimal("0.05") -"""Legacy trailing-stop protection; stop-market uses legal venue bounds.""" class _Stop(ManagedOrder): @@ -347,8 +347,8 @@ def __init__(self, *args: Any, **kwargs: Any): self.trail_percent = D(self.params.get("trail_percent")) if self.trail is None and self.trail_percent is None: raise ValueError("a trailing stop needs params trail or trail_percent") + # The best price seen: the highest for a sell stop, the lowest for a buy. self.extreme: Decimal | None = None - """The best price seen: the highest for a sell stop, the lowest for a buy.""" def distance(self, watched: Decimal) -> Decimal: if self.trail is not None: diff --git a/synpath/engine/orders/twap.py b/synpath/engine/orders/twap.py index 86af2a1..64a4422 100644 --- a/synpath/engine/orders/twap.py +++ b/synpath/engine/orders/twap.py @@ -49,10 +49,10 @@ def __init__(self, *args: Any, **kwargs: Any): self.style = str(self.params.get("style") or "limit") if self.style not in ("limit", "taker"): raise ValueError("a TWAP's style is 'limit' or 'taker'") + # The worst price any slice may pay. `None` means the market's. self.limit = D(self.params.get("limit")) or D(self.request.price) - """The worst price any slice may pay. `None` means the market's.""" + # What the end of the window does with anything unfilled. self.at_end = str(self.params.get("finish") or "complete") - """What the end of the window does with anything unfilled.""" self.started_at: float = 0.0 self.sent_slices = 0 diff --git a/synpath/engine/paper.py b/synpath/engine/paper.py index 3f2f74a..8e39ce7 100644 --- a/synpath/engine/paper.py +++ b/synpath/engine/paper.py @@ -117,8 +117,8 @@ class Resting: """A resting paper order and where it stands in the queue.""" order: Order + # Contracts that must trade at this price before this order fills. queue_ahead: Decimal - """Contracts that must trade at this price before this order fills.""" request: OrderRequest @@ -171,10 +171,10 @@ def __init__( self.positions_held: dict[str, Decimal] = {} self.locked = ZERO self._n = 0 + # Fills made but not yet delivered. A real venue reports a fill on a + # stream, after the call that caused it has returned, and code that + # reacts to fills must work that way here too. self.pending: list[Fill] = [] - """Fills made but not yet delivered. A real venue reports a fill on a - stream, after the call that caused it has returned, and code that - reacts to fills must work that way here too.""" self.listeners: list[Callable[[Fill], Any]] = [] self.order_listeners: list[Callable[[Order], Any]] = [] self.pending_orders: list[Order] = [] @@ -542,25 +542,25 @@ class PaperMaker: """A counterparty that answers every RFQ a `PaperRfqVenue` receives.""" name: str = "maker" + # The YES price it buys at (what a requester selling YES gets). bid: Decimal | None = Decimal("0.40") - """The YES price it buys at (what a requester selling YES gets).""" + # The YES price it sells at (what a requester buying YES pays). ask: Decimal | None = Decimal("0.45") - """The YES price it sells at (what a requester buying YES pays).""" + # Contracts on each side; `None` sizes to the RFQ (its amount at the price). quantity: Decimal | None = None - """Contracts on each side; `None` sizes to the RFQ (its amount at the price).""" + # `False` lets the last look lapse: the accepted quote expires. confirms: bool = True - """`False` lets the last look lapse: the accepted quote expires.""" + # How much of the accepted size the requester's order fills; the rest + # is cancelled, as an IOC order's would be. fill_ratio: Decimal = ONE - """How much of the accepted size the requester's order fills; the rest - is cancelled, as an IOC order's would be.""" + # Report this side as the one accepted, whatever was asked (the venue + # getting the side wrong, for the engine's side guard). report_side: Side | None = None - """Report this side as the one accepted, whatever was asked (the venue - getting the side wrong, for the engine's side guard).""" + # The quote's own expiry, seconds after it is made (`expires_at`, as + # Polymarket global gives one); `None` gives none (Kalshi, Polymarket US). ttl_s: float | None = None - """The quote's own expiry, seconds after it is made (`expires_at`, as - Polymarket global gives one); `None` gives none (Kalshi, Polymarket US).""" + # The quote arrives this long after the RFQ (seen on the next read past it). delay_s: float = 0.0 - """The quote arrives this long after the RFQ (seen on the next read past it).""" class PaperRfqVenue(PaperVenue): @@ -585,27 +585,27 @@ def __init__(self, *, makers: Sequence[PaperMaker] = (), last_look_s: float = 3. auto_advance: bool = False, maker_fill_ratio: Decimal = ONE, stream_incoming: bool = False, rfq_ttl_s: float | None = None, **kw: Any): super().__init__(**kw) + # Our RFQs' expiry, seconds after the create (`Rfq.expires_at`); the + # venue closes one as `expired` once past it. `None`: no expiry. self.rfq_ttl_s = rfq_ttl_s - """Our RFQs' expiry, seconds after the create (`Rfq.expires_at`); the - venue closes one as `expired` once past it. `None`: no expiry.""" self._delayed: list[tuple[int, Any, Any]] = [] + # `True` pushes each `add_incoming_rfq` to `subscribe_incoming_rfqs` + # listeners at once, as a venue stream would; `False` leaves it to be polled. self.stream_incoming = stream_incoming - """`True` pushes each `add_incoming_rfq` to `subscribe_incoming_rfqs` - listeners at once, as a venue stream would; `False` leaves it to be polled.""" self.rfq_listeners: list[Callable[[Any], Any]] = [] self.stream_started = False self.makers: list[PaperMaker] = list(makers) or [PaperMaker()] self.last_look_s = last_look_s self.auto_advance = auto_advance + # How much of a confirmed maker quote fills, on the quoter side. self.maker_fill_ratio = maker_fill_ratio - """How much of a confirmed maker quote fills, on the quoter side.""" self.rfqs: dict[str, Any] = {} self.quotes: dict[str, Any] = {} self.combos: dict[str, Any] = {} self.accepted: dict[str, dict[str, Any]] = {} self.calls: list[str] = [] + # The `collection_id` each `create_combo` was given. self.combo_collections: list[str | None] = [] - """The `collection_id` each `create_combo` was given.""" self.quote_listeners: list[Callable[[Any], Any]] = [] self.pending_quotes: list[Any] = [] self._q = 0 diff --git a/synpath/engine/rfq.py b/synpath/engine/rfq.py index 7afc006..da498da 100644 --- a/synpath/engine/rfq.py +++ b/synpath/engine/rfq.py @@ -79,14 +79,14 @@ ZERO = Decimal("0") ONE = Decimal("1") +# The cross-venue group's own intents (`synpath.engine.rfq_group`). GROUP_OPERATIONS = frozenset({"rfq_group_create", "rfq_group_accept"}) -"""The cross-venue group's own intents (`synpath.engine.rfq_group`).""" +# Intent operations this module owns; `Engine._resolve` hands them here. RFQ_OPERATIONS = frozenset({ "create_rfq", "cancel_rfq", "accept_quote", "send_quote", "replace_quote", "cancel_quote", "confirm_quote", }) | GROUP_OPERATIONS -"""Intent operations this module owns; `Engine._resolve` hands them here.""" +# Operations whose intent stays `sending` until the quote is final. FOLLOWED = frozenset({"accept_quote", "confirm_quote"}) -"""Operations whose intent stays `sending` until the quote is final.""" ACCEPTED_MARK = "accepted at the venue" CONFIRMED_MARK = "confirmed at the venue" @@ -100,10 +100,10 @@ } NO_TRADE = (QuoteStatus.CANCELED, QuoteStatus.EXPIRED, QuoteStatus.FAILED) RECENT_MS = 5_000 +# How often the follower re-reads one of our open RFQs from its venue, to +# see it close there (a venue's own expiry, often unstated, as Kalshi's); at +# once when the venue's `expires_at` for it has passed. RFQ_STATUS_INTERVAL_S = 10.0 -"""How often the follower re-reads one of our open RFQs from its venue, to -see it close there (a venue's own expiry, often unstated, as Kalshi's); at -once when the venue's `expires_at` for it has passed.""" """How far before an in-doubt create an RFQ or quote may be stamped and still be its result.""" @@ -120,23 +120,23 @@ class RfqDesk: def __init__(self, engine: "Engine"): self.engine = engine + # Our open RFQs, `venue:id` -> {rfq, book, trader}: watched for quotes. self.rfqs: dict[str, dict[str, Any]] = {} - """Our open RFQs, `venue:id` -> {rfq, book, trader}: watched for quotes.""" + # Our live maker quotes, `venue:id` -> what the engine holds about them (expiry, book, size). self.maker_quotes: dict[str, dict[str, Any]] = {} - """Our live maker quotes, `venue:id` -> what the engine holds about them (expiry, book, size).""" + # Open RFQs from others, as the incoming feed last saw them. self.incoming: dict[str, Rfq] = {} - """Open RFQs from others, as the incoming feed last saw them.""" self.combos: dict[str, Combo] = {} + # `venue:quote_id` -> the key of the accept or confirm intent following it. self.following: dict[str, str] = {} - """`venue:quote_id` -> the key of the accept or confirm intent following it.""" self._status: dict[str, QuoteStatus] = {} self._last_quote: dict[str, RfqQuote] = {} self._subscribed: set[str] = set() self._incoming_ts = 0.0 self._rfq_read: dict[str, float] = {} + # Venues whose incoming RFQs arrive on a stream (`subscribe_incoming_rfqs`): + # not polled, and their quoting callbacks run as each RFQ lands. self.pushed: set[str] = set() - """Venues whose incoming RFQs arrive on a stream (`subscribe_incoming_rfqs`): - not polled, and their quoting callbacks run as each RFQ lands.""" self._incoming_subscribed: set[str] = set() self._streams_started: set[str] = set() self._incoming_callbacks: list[Callable[[Rfq], Any]] = [] diff --git a/synpath/engine/rfq_group.py b/synpath/engine/rfq_group.py index 09200b0..f7d7883 100644 --- a/synpath/engine/rfq_group.py +++ b/synpath/engine/rfq_group.py @@ -87,40 +87,40 @@ ONE = Decimal("1") GROUPS_CURSOR = "rfq:groups" +# Pages of a venue's combo-eligible list (or of its collections) read +# looking for a group's legs before a leg not yet seen counts as ineligible. ELIGIBLE_PAGES = 20 -"""Pages of a venue's combo-eligible list (or of its collections) read -looking for a group's legs before a leg not yet seen counts as ineligible.""" +# How long a venue's combo-eligible list is trusted between groups. ELIGIBLE_TTL_S = 60.0 -"""How long a venue's combo-eligible list is trusted between groups.""" +# How long a matching answer (a listing, or none) is reused: a preview and +# the group it leads to ask the same legs within seconds. MATCH_TTL_S = 60.0 -"""How long a matching answer (a listing, or none) is reused: a preview and -the group it leads to ask the same legs within seconds.""" +# `labeler(legs)` -> a short readable name for each leg's market, by market +# id (`Al Fayha to win`, `U.S. invades Iran`). serve's combo board warmer is +# one (`synpath.server.combo_warmer`); without one a leg is named by its +# `info["label"]`, else its market id. Labeler = Callable[[list[ComboLeg]], Awaitable[dict[str, str]]] -"""`labeler(legs)` -> a short readable name for each leg's market, by market -id (`Al Fayha to win`, `U.S. invades Iran`). serve's combo board warmer is -one (`synpath.server.combo_warmer`); without one a leg is named by its -`info["label"]`, else its market id.""" VENUE_NAMES = {"kalshi": "Kalshi", "polymarket": "Polymarket", "polymarket_us": "Polymarket US", "limitless": "Limitless", "opinion": "Opinion", "predict_fun": "predict.fun", "sx_bet": "SX Bet"} +# What a collection venue's combos take, said when a leg's event is in none +# of its open collections. COLLECTION_SCOPES = {"kalshi": "Kalshi combos are sports and cross-category only"} -"""What a collection venue's combos take, said when a leg's event is in none -of its open collections.""" +# Why a venue lacks a leg, in the order a skip reason names them. LEG_CODES = ("missing_leg", "matching_error", "ineligible_leg", "outside_collections") -"""Why a venue lacks a leg, in the order a skip reason names them.""" +# `matcher(anchor, venue)` -> the other venue's listing of the same +# proposition, or `None` when it has none. Raises `MarketNotFound` when the +# anchor is not a listing the service knows. Matcher = Callable[[str, str], Awaitable[MarketLink | None]] -"""`matcher(anchor, venue)` -> the other venue's listing of the same -proposition, or `None` when it has none. Raises `MarketNotFound` when the -anchor is not a listing the service knows.""" GroupStatus = Literal["creating", "open", "accepting", "accepted", "executed", "canceled", "expired", "failed"] MemberStatus = Literal["creating", "open", "failed", "canceled", "lapsing", "closed", "accepting", "accepted", "executed"] FINAL = frozenset({"executed", "canceled", "expired", "failed"}) +# A claim in one of these states no longer holds its group. RELEASED = (IntentState.FAILED, IntentState.REJECTED, IntentState.LOST) -"""A claim in one of these states no longer holds its group.""" def _now_ms() -> int: @@ -366,13 +366,13 @@ class RfqGroupDesk: def __init__(self, engine: "Engine", *, matcher: Matcher | None = None): self.engine = engine self.matcher: Matcher = matcher or hosted_matcher() + # Names each leg's market in a skip reason (`Labeler`); set by serve. self.labeler: Labeler | None = None - """Names each leg's market in a skip reason (`Labeler`); set by serve.""" self.groups: dict[str, RfqGroup] = {} + # `venue:rfq_id` -> the group it is a member of. self._by_rfq: dict[str, str] = {} - """`venue:rfq_id` -> the group it is a member of.""" + # Groups an accept or a cancel is working on in this process. self._busy: set[str] = set() - """Groups an accept or a cancel is working on in this process.""" self._fees: dict[tuple[str, str, str, Decimal, Decimal], Decimal | None] = {} self._matches: dict[tuple[str, str], tuple[float, MarketLink | None]] = {} self._eligible_lists: dict[str, tuple[float, dict[str, ComboMarket], bool]] = {} diff --git a/synpath/engine/risk.py b/synpath/engine/risk.py index ae2401c..511a385 100644 --- a/synpath/engine/risk.py +++ b/synpath/engine/risk.py @@ -63,37 +63,37 @@ class RiskConfig(BaseModel): enabled: bool = True # -- this order -- + # How far from the mark a limit may sit, in price units (contracts are + # priced 0 to 1, so 0.10 is ten cents). `None` disables the check. price_collar: Decimal | None = Decimal("0.10") - """How far from the mark a limit may sit, in price units (contracts are - priced 0 to 1, so 0.10 is ten cents). `None` disables the check.""" max_order_contracts: Decimal | None = None + # Priced outcome cost times contracts, in account currency, excluding fees. max_order_notional: Decimal | None = None - """Priced outcome cost times contracts, in account currency, excluding fees.""" min_price: Decimal = Decimal("0") max_price: Decimal = Decimal("1") + # The same instrument, side, price and size within this window is a retry. duplicate_window_ms: int = 2_000 - """The same instrument, side, price and size within this window is a retry.""" + # Refuse opening orders this close to a market's resolution time. closing_soon_s: int | None = 60 - """Refuse opening orders this close to a market's resolution time.""" + # Instrument or market ids, or prefixes ending in `*`, nobody may trade. restricted: list[str] = Field(default_factory=list) - """Instrument or market ids, or prefixes ending in `*`, nobody may trade.""" # -- the book -- max_open_orders: int | None = 200 + # Per instrument, per account, netted across books. max_position_contracts: Decimal | None = None - """Per instrument, per account, netted across books.""" max_event_contracts: Decimal | None = None max_venue_notional: Decimal | None = None + # Realized loss since the day's start, as a positive number. daily_loss_limit: Decimal | None = None - """Realized loss since the day's start, as a positive number.""" max_orders_per_minute: int | None = 120 # -- the firm -- self_trade_prevention: SelfTradeScope = "firm" + # Venue-published position limits, per market id. exchange_limits: dict[str, Decimal] = Field(default_factory=dict) - """Venue-published position limits, per market id.""" + # Strategies that may cancel but not open. paused_books: list[str] = Field(default_factory=list) - """Strategies that may cancel but not open.""" def paused(self, book: str | None) -> bool: return bool(book and book in self.paused_books) @@ -132,8 +132,8 @@ class KillSwitch: """Stop trading now, and say what happens to what is already resting.""" engaged: bool = False + # `*` for everything, or a venue id. scope: str = "*" - """`*` for everything, or a venue id.""" policy: HaltPolicy = "cancel" reason: str = "" engaged_ts: int | None = None diff --git a/synpath/engine/router.py b/synpath/engine/router.py index cfc4d12..95f925d 100644 --- a/synpath/engine/router.py +++ b/synpath/engine/router.py @@ -43,14 +43,14 @@ from ..types import OrderBook ZERO = Decimal("0") +# `fee(market_id, price, contracts)` -> the taker fee for that many contracts +# at that price, in the venue's currency. The plan asks per level. FeeFn = Callable[[str, Decimal, Decimal], Decimal] -"""`fee(market_id, price, contracts)` -> the taker fee for that many contracts -at that price, in the venue's currency. The plan asks per level.""" Reason = Literal["", "worst_price", "liquidity", "min_quantity"] +# Per market id, the least one taker order pays. Absent means no floor. FeeFloors = Mapping[str, Decimal] -"""Per market id, the least one taker order pays. Absent means no floor.""" PriceSize = tuple[Decimal, Decimal] @@ -117,10 +117,10 @@ def default_precision(market_id: str) -> Precision: @dataclass(frozen=True) class Level: market_id: str + # In bucket terms: what one contract of the bucket's YES costs (asks) or fetches (bids). price: Decimal - """In bucket terms: what one contract of the bucket's YES costs (asks) or fetches (bids).""" + # `price` plus the taker fee per contract on a buy, minus it on a sell. net_price: Decimal - """`price` plus the taker fee per contract on a buy, minus it on a sell.""" size: Decimal @@ -128,20 +128,20 @@ class Level: class Leg: market_id: str flip: bool + # The member's own side, after `flip`. side: Side - """The member's own side, after `flip`.""" + # The member's own YES price, after `flip` and tick rounding. What the venue receives. price: Decimal - """The member's own YES price, after `flip` and tick rounding. What the venue receives.""" quantity: Decimal + # The worst bucket-terms price this leg reaches, before fees. bucket_price: Decimal - """The worst bucket-terms price this leg reaches, before fees.""" + # Volume-weighted net price of the levels this leg takes, in bucket terms, + # with the venue's fee floor included where it binds. net_price: Decimal - """Volume-weighted net price of the levels this leg takes, in bucket terms, - with the venue's fee floor included where it binds.""" + # The taker fee this leg is expected to pay, floor included. fee: Decimal = ZERO - """The taker fee this leg is expected to pay, floor included.""" + # How much of `fee` is the floor over what the levels' fees add up to. fee_floor: Decimal = ZERO - """How much of `fee` is the floor over what the levels' fees add up to.""" def notional(self) -> Decimal: """What the token this leg buys costs: YES at `price` on a buy, NO at diff --git a/synpath/engine/storage_types.py b/synpath/engine/storage_types.py index 0c57867..a32c914 100644 --- a/synpath/engine/storage_types.py +++ b/synpath/engine/storage_types.py @@ -11,18 +11,18 @@ class LeaseLost(RuntimeError): class IntentState: + # Written, risk passed, not yet sent. PLANNED = "planned" - """Written, risk passed, not yet sent.""" + # Handed to the venue; the answer has not come back. SENDING = "sending" - """Handed to the venue; the answer has not come back.""" SENT = "sent" + # The order reached a terminal status. SETTLED = "settled" - """The order reached a terminal status.""" REJECTED = "rejected" + # The venue refused it, or the call failed in a way that proves nothing left. FAILED = "failed" - """The venue refused it, or the call failed in a way that proves nothing left.""" + # In doubt for longer than the sweep allows and not found at the venue. LOST = "lost" - """In doubt for longer than the sweep allows and not found at the venue.""" @dataclass(frozen=True, slots=True) diff --git a/synpath/errors.py b/synpath/errors.py index c03b6a9..46d2602 100644 --- a/synpath/errors.py +++ b/synpath/errors.py @@ -47,8 +47,8 @@ class RateLimitExceeded(NetworkError): def __init__(self, message: str, *, retry_after: float | None = None): super().__init__(message) + # Seconds to wait, when the venue said. Kalshi does not, so usually None. self.retry_after = retry_after - """Seconds to wait, when the venue said. Kalshi does not, so usually None.""" class ExchangeError(SynpathError): diff --git a/synpath/hyperliquid.py b/synpath/hyperliquid.py index 594da20..cd7234b 100644 --- a/synpath/hyperliquid.py +++ b/synpath/hyperliquid.py @@ -56,45 +56,45 @@ TESTNET_INFO_URL = "https://api.hyperliquid-testnet.xyz" VENUE = "hyperliquid" +# The venue weighs info requests (a book 2, a catalog 20) against 1200 a +# minute per IP. Five a second keeps a burst of book reads inside it; the +# heavy catalog reads are cached, not repeated. LIMITER = RateLimiter(5.0, burst=10) -"""The venue weighs info requests (a book 2, a catalog 20) against 1200 a -minute per IP. Five a second keeps a burst of book reads inside it; the -heavy catalog reads are cached, not repeated.""" +# Seconds a read of the catalog and the 24h figures is reused. Outcomes are +# listed and settled on the scale of minutes, and every call here starts from +# the catalog, so re-reading it per call would spend the rate budget on the +# same document. CATALOG_TTL = 30.0 -"""Seconds a read of the catalog and the 24h figures is reused. Outcomes are -listed and settled on the scale of minutes, and every call here starts from -the catalog, so re-reading it per call would spend the rate budget on the -same document.""" +# Seconds the template texts are reused: they change only when the venue +# adds a template. TEMPLATES_TTL = 3600.0 -"""Seconds the template texts are reused: they change only when the venue -adds a template.""" BASE_TAKER_RATE = 0.0007 +# HyperCore's spot fees at the lowest volume tier, which outcome trading +# uses. A higher tier pays less; the account's own tier is not known here. BASE_MAKER_RATE = 0.0004 -"""HyperCore's spot fees at the lowest volume tier, which outcome trading -uses. A higher tier pays less; the account's own tier is not known here.""" +# The venue's minimum order value, in the quote token. MIN_NOTIONAL = 10.0 -"""The venue's minimum order value, in the quote token.""" +# Bars looked back when neither `since` nor `limit` bounds the window. DEFAULT_BARS = 100 -"""Bars looked back when neither `since` nor `limit` bounds the window.""" +# Bar widths `candleSnapshot` serves. A timeframe this library offers that +# the venue does not (6h) is built from the widest one that divides it. CANDLE_INTERVALS = ("1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "8h", "12h", "1d") -"""Bar widths `candleSnapshot` serves. A timeframe this library offers that -the venue does not (6h) is built from the widest one that divides it.""" +# Marks a question's id (`q198`), so it cannot be read as an outcome's. QUESTION_PREFIX = "q" -"""Marks a question's id (`q198`), so it cannot be read as an outcome's.""" +# Fields that say when the thing a market is about happens, in the order +# they are preferred as its close time. TIME_FIELDS = ("time", "expiry", "scheduledStart", "scheduledDecision", "dateTime", "listingDeadline") -"""Fields that say when the thing a market is about happens, in the order -they are preferred as its close time.""" +# Fields that say when a market resolves at the latest. DEADLINE_FIELDS = ("resolutionDeadline", "decisionDeadline", "listingDeadline", "time", "expiry", "dateTime") -"""Fields that say when a market resolves at the latest.""" # --------------------------------------------------------------------------- diff --git a/synpath/ids.py b/synpath/ids.py index 44e2d3a..bfeea06 100644 --- a/synpath/ids.py +++ b/synpath/ids.py @@ -16,10 +16,10 @@ """ from __future__ import annotations +# Every venue this library speaks, by id. `synpath.exchanges` is the same +# list with the adapter classes attached; this one exists so the id helpers do +# not import the adapters. VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets") -"""Every venue this library speaks, by id. `synpath.exchanges` is the same -list with the adapter classes attached; this one exists so the id helpers do -not import the adapters.""" def qualify(venue: str, native_id: str) -> str: diff --git a/synpath/kalshi.py b/synpath/kalshi.py index 8abfc34..664ed3e 100644 --- a/synpath/kalshi.py +++ b/synpath/kalshi.py @@ -39,66 +39,65 @@ BASE_URL = "https://external-api.kalshi.com/trade-api/v2" +# Where Kalshi's text search lives. +# +# Not part of the documented Trade API, which has no search at all: its `/events` +# endpoint accepts a `query` parameter and silently ignores it, returning the +# unfiltered first page. This is the endpoint kalshi.com's own search box calls, +# on a different host -- `external-api.kalshi.com` answers 404 for it. +# +# Being undocumented, it can change or start requiring credentials without +# notice. When it fails, `search_markets` raises rather than falling back to +# scanning the catalog: a scan for a term that matches nothing walks ~13,000 +# events, and turning a fast failure into a hundred-second one is not a +# kindness. SEARCH_URL = "https://api.elections.kalshi.com" -"""Where Kalshi's text search lives. - -Not part of the documented Trade API, which has no search at all: its `/events` -endpoint accepts a `query` parameter and silently ignores it, returning the -unfiltered first page. This is the endpoint kalshi.com's own search box calls, -on a different host -- `external-api.kalshi.com` answers 404 for it. - -Being undocumented, it can change or start requiring credentials without -notice. When it fails, `search_markets` raises rather than falling back to -scanning the catalog: a scan for a term that matches nothing walks ~13,000 -events, and turning a fast failure into a hundred-second one is not a -kindness. -""" +# Deliberately slower than the trade API's limiter. This host publishes no +# limits and is not part of the documented surface, so it gets the conservative +# end of the guess. SEARCH_LIMITER = RateLimiter(2.0, burst=2) -"""Deliberately slower than the trade API's limiter. This host publishes no -limits and is not part of the documented surface, so it gets the conservative -end of the guess.""" VENUE = "kalshi" +# Kalshi rate-limits its public reads without documenting it in a header. +# Measured: an unpaced burst starts returning 429 around 8 req/s, 5.2 req/s ran +# clean, and a 429 clears in about a second. Module-level, so every client in the +# process shares one budget — which is what the venue actually counts. LIMITER = RateLimiter(5.0, burst=5) -"""Kalshi rate-limits its public reads without documenting it in a header. -Measured: an unpaced burst starts returning 429 around 8 req/s, 5.2 req/s ran -clean, and a 429 clears in about a second. Module-level, so every client in the -process shares one budget — which is what the venue actually counts.""" +# Tickers per `/markets` call. The ceiling is URI length, not a count (500 is +# fine, 800 returns 414), so this sits well under it rather than relying on a +# limit that depends on how long tickers happen to be. MARKET_BATCH = 200 -"""Tickers per `/markets` call. The ceiling is URI length, not a count (500 is -fine, 800 returns 414), so this sits well under it rather than relying on a -limit that depends on how long tickers happen to be.""" _SETTLED = {"settled", "determined", "finalized"} _OPEN = {"active", "open"} _UNOPENED = {"unopened", "initialized"} +# Results per search page. The venue's own page size; asking for more is +# ignored. SEARCH_PAGE = 50 -"""Results per search page. The venue's own page size; asking for more is -ignored.""" +# Event tickers per `/events?tickers=` call. A full page of search results is +# at most `MAX_PAGE_LIMIT` rows, so one batch covers it; 100 tickers measured at +# under 1,900 characters of URL. EVENT_BATCH = 100 -"""Event tickers per `/events?tickers=` call. A full page of search results is -at most `MAX_PAGE_LIMIT` rows, so one batch covers it; 100 tickers measured at -under 1,900 characters of URL.""" +# The most events Kalshi returns per `/events` call. Used when walking the +# whole catalog, where there is no page contract to honour and every extra +# request is spent rate budget. VENUE_EVENT_PAGE = 200 -"""The most events Kalshi returns per `/events` call. Used when walking the -whole catalog, where there is no page contract to honour and every extra -request is spent rate budget.""" EVENT_PAGE = 25 +# Most event pages one `fetch_markets` call will read while filling a page. +# +# A status filter can leave an event page with few matching markets, and filling +# `limit` from sparse pages must not turn into walking the catalog inside one +# call. Past this, the call returns what it has with a cursor to continue, which +# is what a short page with a cursor means. MAX_FILL_PAGES = 10 -"""Most event pages one `fetch_markets` call will read while filling a page. - -A status filter can leave an event page with few matching markets, and filling -`limit` from sparse pages must not turn into walking the catalog inside one -call. Past this, the call returns what it has with a cursor to continue, which -is what a short page with a cursor means.""" """Events fetched per underlying request while filling a market page. Kalshi averages about nine markets per event, so 25 events covers a full @@ -106,37 +105,37 @@ that overflows is resumed mid-way rather than re-walked. """ +# How many markets' face values to remember. Bounded because the server keeps +# one adapter for the life of the process. FACE_VALUE_CACHE = 2048 -"""How many markets' face values to remember. Bounded because the server keeps -one adapter for the life of the process.""" +# The only period_interval values Kalshi accepts, in minutes. CANDLE_INTERVALS = {"1m": 1, "1h": 60, "1d": 1440} -"""The only period_interval values Kalshi accepts, in minutes.""" +# Most bars Kalshi answers per candlestick request; a wider window is refused +# with `max candlesticks: 5000` (about 3.5 days of 1m, 208 days of 1h). Longer +# windows are read in pieces of `MAX_CANDLES - 1` periods and joined. MAX_CANDLES = 5000 -"""Most bars Kalshi answers per candlestick request; a wider window is refused -with `max candlesticks: 5000` (about 3.5 days of 1m, 208 days of 1h). Longer -windows are read in pieces of `MAX_CANDLES - 1` periods and joined.""" +# Prefix of a `fetch_trades` cursor that has moved past the live tape into +# `/historical/trades`. Kalshi keeps recent data on its live endpoints and moves +# anything older than a cutoff (see `/historical/cutoff`) to `/historical/*`: +# markets settled before it, and trades created before it, are no longer on the +# live endpoints at all. A plain cursor is the live tape's own. HISTORICAL_CURSOR = "historical:" -"""Prefix of a `fetch_trades` cursor that has moved past the live tape into -`/historical/trades`. Kalshi keeps recent data on its live endpoints and moves -anything older than a cutoff (see `/historical/cutoff`) to `/historical/*`: -markets settled before it, and trades created before it, are no longer on the -live endpoints at all. A plain cursor is the live tape's own.""" +# Statuses whose listings continue past the live catalog into +# `/historical/markets`, where Kalshi keeps markets settled before its cutoff. +# The live `/events` endpoint still lists those events, but with no markets +# inside them. ARCHIVE_STATUSES = {"settled", "all"} -"""Statuses whose listings continue past the live catalog into -`/historical/markets`, where Kalshi keeps markets settled before its cutoff. -The live `/events` endpoint still lists those events, but with no markets -inside them.""" +# Most markets `/historical/markets` returns per call; 1001 is a 400. ARCHIVE_PAGE = 1000 -"""Most markets `/historical/markets` returns per call; 1001 is a 400.""" +# Seconds to remember the historical cutoff. Kalshi moves it forward in steps +# of weeks, so asking once an hour is plenty. CUTOFF_TTL = 3600.0 -"""Seconds to remember the historical cutoff. Kalshi moves it forward in steps -of weeks, so asking once an hour is plenty.""" # --------------------------------------------------------------------------- diff --git a/synpath/limitless.py b/synpath/limitless.py index 20105de..565dd21 100644 --- a/synpath/limitless.py +++ b/synpath/limitless.py @@ -47,32 +47,32 @@ VENUE = "limitless" WEB_URL = "https://limitless.exchange/markets/" +# The venue publishes no limit for its public endpoints; kept modest, shared +# process-wide. LIMITER = RateLimiter(5.0, burst=10) -"""The venue publishes no limit for its public endpoints; kept modest, shared -process-wide.""" +# Rows per listing page: the venue's maximum. PAGE = 25 -"""Rows per listing page: the venue's maximum.""" +# Most listing pages one walk reads: 2,500 rows, against about 560 today. MAX_PAGES = 100 -"""Most listing pages one walk reads: 2,500 rows, against about 560 today.""" +# Sizes, volumes and amounts are 6-decimal integers (USDC, and shares). SCALE = 1_000_000 -"""Sizes, volumes and amounts are 6-decimal integers (USDC, and shares).""" +# Prices are quoted to a tenth of a cent. TICK = 0.001 -"""Prices are quoted to a tenth of a cent.""" +# The venue's lifecycle as this library's status. A market trades only while +# `FUNDED` and not yet expired; `CREATED` and `DRAFTED` have not opened. STATUSES = {"FUNDED": "open", "LOCKED": "closed", "RESOLVED": "settled", "CREATED": "closed", "DRAFTED": "closed"} -"""The venue's lifecycle as this library's status. A market trades only while -`FUNDED` and not yet expired; `CREATED` and `DRAFTED` have not opened.""" +# This library's sort keys the venue can order the listing by. It has no +# volume or liquidity order. SORT_BY = {"newest": "newest"} -"""This library's sort keys the venue can order the listing by. It has no -volume or liquidity order.""" +# The price-history lookback presets, shortest first; `all` covers the rest. LOOKBACKS = (("5m", 300), ("1h", 3600), ("6h", 6 * 3600), ("1d", 86400), ("1w", 7 * 86400), ("1m", 30 * 86400)) -"""The price-history lookback presets, shortest first; `all` covers the rest.""" DEFAULT_BARS = 96 diff --git a/synpath/matching.py b/synpath/matching.py index 49853be..bf81430 100644 --- a/synpath/matching.py +++ b/synpath/matching.py @@ -33,9 +33,9 @@ from .types import EventMatch, MarketLink, MarketMatch BASE_URL_ENV = "SYNPATH_MATCHING_URL" +# Where the hosted matching service lives: Synpath's own, unless `base_url=` or +# `SYNPATH_MATCHING_URL` points somewhere else (a staging deployment, a local one). DEFAULT_URL = "https://api.synpath.dev" -"""Where the hosted matching service lives: Synpath's own, unless `base_url=` or -`SYNPATH_MATCHING_URL` points somewhere else (a staging deployment, a local one).""" def _client(base_url: str | None, http_client: Any = None) -> HttpClient: diff --git a/synpath/opinion.py b/synpath/opinion.py index c3524e6..6e52203 100644 --- a/synpath/opinion.py +++ b/synpath/opinion.py @@ -44,56 +44,56 @@ ) OPENAPI_URL = "https://openapi.opinion.trade/openapi" +# A public BNB Chain node, for the one contract read this adapter makes. RPC_URL = "https://bsc-dataseed.binance.org" -"""A public BNB Chain node, for the one contract read this adapter makes.""" VENUE = "opinion" +# The venue's published public limit: 5 requests a second per IP (15 per +# API key). Shared process-wide, as every venue's is. LIMITER = RateLimiter(5.0, burst=5) -"""The venue's published public limit: 5 requests a second per IP (15 per -API key). Shared process-wide, as every venue's is.""" +# Topics per catalog call: the venue's maximum. PAGE = 20 -"""Topics per catalog call: the venue's maximum.""" +# Most catalog pages one `fetch_markets` or `fetch_events` call reads while +# filling `limit`, at 5 requests a second about two seconds. Past it the call +# returns what it has, with a cursor. MAX_PAGES = 10 -"""Most catalog pages one `fetch_markets` or `fetch_events` call reads while -filling `limit`, at 5 requests a second about two seconds. Past it the call -returns what it has, with a cursor.""" +# Most catalog pages one search call scans: 500 topics, more than the whole +# open catalog (about 300 topics) and two seconds at the public rate limit. +# The venue has no search endpoint, so a query is matched here. MAX_SEARCH_PAGES = 25 -"""Most catalog pages one search call scans: 500 topics, more than the whole -open catalog (about 300 topics) and two seconds at the public rate limit. -The venue has no search endpoint, so a query is matched here.""" +# Most `price-history` calls one `fetch_ohlcv` makes walking back to `since`. +# A call returns at most 1000 samples, so this is 10,000 hours (over a year) of +# hourly samples. MAX_HISTORY_PAGES = 10 -"""Most `price-history` calls one `fetch_ohlcv` makes walking back to `since`. -A call returns at most 1000 samples, so this is 10,000 hours (over a year) of -hourly samples.""" +# Bars looked back when neither `since` nor `limit` bounds the window. DEFAULT_BARS = 100 -"""Bars looked back when neither `since` nor `limit` bounds the window.""" +# Opinion's FeeManager contract on BNB Chain, from the venue's own SDK. FEE_MANAGER = "0xC9063Dc52dEEfb518E5b6634A6b8D624bc5d7c36" -"""Opinion's FeeManager contract on BNB Chain, from the venue's own SDK.""" +# Selector of `getFeeRateSettings(uint256 tokenId)`, which returns +# `(makerFeeRateBps, takerFeeRateBps, enabled, minFeeAmount)`. GET_FEE_RATE_SETTINGS = "0x27f68850" -"""Selector of `getFeeRateSettings(uint256 tokenId)`, which returns -`(makerFeeRateBps, takerFeeRateBps, enabled, minFeeAmount)`.""" +# The venue's documented minimum fee per order, in USDT. The contract's own +# `minFeeAmount` reads 0; the fee docs state this floor is applied when the +# curve fee comes out smaller, so the documented figure is the one used. MIN_FEE = 0.25 -"""The venue's documented minimum fee per order, in USDT. The contract's own -`minFeeAmount` reads 0; the fee docs state this floor is applied when the -curve fee comes out smaller, so the documented figure is the one used.""" +# The venue's numeric status, as (normalized status, native word). STATUSES = {1: ("unopened", "created"), 2: ("open", "activated"), 3: ("closed", "resolving"), 4: ("settled", "resolved")} -"""The venue's numeric status, as (normalized status, native word).""" +# This library's sort keys as the venue's `sortBy` codes: 5 is 24h volume, +# descending; 1 is newest first. The venue has no liquidity ordering. SORT_BY = {"volume": 5, "newest": 1} -"""This library's sort keys as the venue's `sortBy` codes: 5 is 24h volume, -descending; 1 is newest first. The venue has no liquidity ordering.""" +# The venue's error number for an id it does not know ("Topic ID does not exist"). NOT_FOUND = 10200 -"""The venue's error number for an id it does not know ("Topic ID does not exist").""" # --------------------------------------------------------------------------- @@ -464,8 +464,8 @@ def __init__( client = httpx.Client(timeout=timeout, follow_redirects=True, headers={"apikey": api_key}) self.http = HttpClient(openapi_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) self.rpc = HttpClient(rpc_url, limiter=None, timeout=timeout, venue=VENUE) + # Per market: `(yes_token, no_token)`, filled by every catalog read. self._tokens: dict[str, tuple[str, str]] = {} - """Per market: `(yes_token, no_token)`, filled by every catalog read.""" # -- catalog ------------------------------------------------------------ diff --git a/synpath/polymarket.py b/synpath/polymarket.py index 638ad17..9cee0ac 100644 --- a/synpath/polymarket.py +++ b/synpath/polymarket.py @@ -40,51 +40,51 @@ DATA_URL = "https://data-api.polymarket.com" VENUE = "polymarket" +# Gamma took 30 requests at 43/s without complaint, so this is headroom rather +# than a measured ceiling: present so an undocumented limit throttles us instead +# of failing us. Shared process-wide, as Kalshi's is. LIMITER = RateLimiter(20.0, burst=20) -"""Gamma took 30 requests at 43/s without complaint, so this is headroom rather -than a measured ceiling: present so an undocumented limit throttles us instead -of failing us. Shared process-wide, as Kalshi's is.""" +# Tokens per POST /books. 500 answered in 0.32s; 672 was "Payload exceeds the limit". BOOK_BATCH = 500 -"""Tokens per POST /books. 500 answered in 0.32s; 672 was "Payload exceeds the limit".""" +# Gamma enforces this: 200 ids returns `422 expected array length <= 100`. MARKET_BATCH = 100 -"""Gamma enforces this: 200 ids returns `422 expected array length <= 100`.""" +# Gamma events per `GET /events?id=` read: each carries every market of its event. EVENT_BATCH = 20 -"""Gamma events per `GET /events?id=` read: each carries every market of its event.""" +# Rows per Data API `/trades` call, its maximum. TRADE_PAGE = 500 -"""Rows per Data API `/trades` call, its maximum.""" +# Most tape pages one `fetch_ohlcv` call reads: 10,000 trades. A hot market +# was measured printing 500 trades in 68 minutes, so this is a few hours of +# one and weeks of a quiet one. Past it the call stops and marks the earliest +# bar incomplete rather than walking the tape indefinitely. MAX_TRADE_PAGES = 20 -"""Most tape pages one `fetch_ohlcv` call reads: 10,000 trades. A hot market -was measured printing 500 trades in 68 minutes, so this is a few hours of -one and weeks of a quiet one. Past it the call stops and marks the earliest -bar incomplete rather than walking the tape indefinitely.""" +# Widest `startTs`..`endTs` span, in seconds, one `prices-history` request is +# sent. The venue refuses a wider one (`interval is too long`; 15 days measured +# fine, 16 refused), so longer windows are read in pieces and joined. QUOTE_WINDOW = 14 * 86400 -"""Widest `startTs`..`endTs` span, in seconds, one `prices-history` request is -sent. The venue refuses a wider one (`interval is too long`; 15 days measured -fine, 16 refused), so longer windows are read in pieces and joined.""" +# Bars looked back when neither `since` nor `limit` bounds the window. DEFAULT_BARS = 100 -"""Bars looked back when neither `since` nor `limit` bounds the window.""" +# Events per `/public-search` page: its maximum; a larger `limit_per_type` +# is answered with 50. SEARCH_PAGE = 50 -"""Events per `/public-search` page: its maximum; a larger `limit_per_type` -is answered with 50.""" +# Most search pages one `fetch_markets(query=...)` call reads while filling +# `limit` markets. Past it the call returns what it has, with a cursor. MAX_SEARCH_PAGES = 5 -"""Most search pages one `fetch_markets(query=...)` call reads while filling -`limit` markets. Past it the call returns what it has, with a cursor.""" +# The deepest offset the Data API's `/trades` accepts; past it the venue +# answers `max historical trades offset of 10000 exceeded`. MAX_TRADE_OFFSET = 10_000 -"""The deepest offset the Data API's `/trades` accepts; past it the venue -answers `max historical trades offset of 10000 exceeded`.""" +# This library's sort keys as Gamma's own field names. SORT_FIELDS = {"volume": "volume24hr", "liquidity": "liquidityNum", "newest": "startDate"} -"""This library's sort keys as Gamma's own field names.""" from .base import MAX_PAGE_LIMIT # noqa: E402 (kept next to the cap it mirrors) """Gamma's keyset endpoints cap a page at 100 rows whatever is asked for, which @@ -542,8 +542,8 @@ def __init__( self.gamma = HttpClient(gamma_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) self.clob = HttpClient(clob_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) self.data = HttpClient(data_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) + # Per market: `(yes_token, no_token, condition_id)`, filled by every catalog read. self._tokens: dict[str, tuple[str, str, str]] = {} - """Per market: `(yes_token, no_token, condition_id)`, filled by every catalog read.""" # -- catalog ------------------------------------------------------------ diff --git a/synpath/polymarket_us.py b/synpath/polymarket_us.py index 8972de5..c4b6bd3 100644 --- a/synpath/polymarket_us.py +++ b/synpath/polymarket_us.py @@ -55,37 +55,37 @@ SITE_URL = "https://polymarket.us" VENUE = "polymarket_us" +# The venue documents 20 requests per second per IP for the public gateway, +# answering 429 with no Retry-After beyond that. Set below the ceiling because +# the limit is per IP and this budget is per process: two processes on one +# machine each at 20/s would be at 40/s. Shared process-wide, as the others are. LIMITER = RateLimiter(15.0, burst=15) -"""The venue documents 20 requests per second per IP for the public gateway, -answering 429 with no Retry-After beyond that. Set below the ceiling because -the limit is per IP and this budget is per process: two processes on one -machine each at 20/s would be at 40/s. Shared process-wide, as the others are.""" +# Slugs per `/markets?slug=` call. The ceiling is URI length; 50 slugs of the +# lengths seen here stay under 2,500 characters. MARKET_BATCH = 50 -"""Slugs per `/markets?slug=` call. The ceiling is URI length; 50 slugs of the -lengths seen here stay under 2,500 characters.""" +# The venue's published maker coefficient, a rebate. Fees are `theta * C * p +# * (1 - p)`; the taker theta is on each market as `feeCoefficient`, the maker +# theta is venue-wide and appears only in the fee schedule document. MAKER_THETA = -0.0125 -"""The venue's published maker coefficient, a rebate. Fees are `theta * C * p -* (1 - p)`; the taker theta is on each market as `feeCoefficient`, the maker -theta is venue-wide and appears only in the fee schedule document.""" +# Most search pages one `search_markets` call reads while filling `limit` +# markets. Past it the call returns what it has, with a cursor. MAX_SEARCH_PAGES = 5 -"""Most search pages one `search_markets` call reads while filling `limit` -markets. Past it the call returns what it has, with a cursor.""" +# Packs a search cursor's page and markets-to-skip into one number: +# `page * SEARCH_SKIP + skip`. SEARCH_SKIP = 1_000_000 -"""Packs a search cursor's page and markets-to-skip into one number: -`page * SEARCH_SKIP + skip`.""" +# Results per search page when the caller gives no limit. SEARCH_PAGE = 20 -"""Results per search page when the caller gives no limit.""" _SETTLED_STATUSES = {"MARKET_STATUS_RESOLVED", "MARKET_STATUS_SETTLED"} +# Book states in which the venue accepts orders. Everything else -- +# pre-open, suspended, halted, expired, terminated, auction -- is listed and not +# trading, which is `status="open"` with `active=False`. _OPEN_STATES = {"MARKET_STATE_OPEN"} -"""Book states in which the venue accepts orders. Everything else -- -pre-open, suspended, halted, expired, terminated, auction -- is listed and not -trading, which is `status="open"` with `active=False`.""" # --------------------------------------------------------------------------- diff --git a/synpath/predict_fun.py b/synpath/predict_fun.py index fa9760e..cb7bbc2 100644 --- a/synpath/predict_fun.py +++ b/synpath/predict_fun.py @@ -46,32 +46,32 @@ VENUE = "predict_fun" WEB_URL = "https://predict.fun/market/" +# The venue allows 240 requests a minute per API key; kept just under it, +# shared process-wide. LIMITER = RateLimiter(3.8, burst=8) -"""The venue allows 240 requests a minute per API key; kept just under it, -shared process-wide.""" +# Amounts and prices on trades are 18-decimal integers (USDT and shares on BNB Chain). WEI = 10**18 -"""Amounts and prices on trades are 18-decimal integers (USDT and shares on BNB Chain).""" +# The venue's market lifecycle, as this library's status. A proposed or +# disputed price is the outcome being decided: trading has stopped, nothing is +# paid yet. STATUSES = { "REGISTERED": "open", "UNPAUSED": "open", "PAUSED": "closed", "PRICE_PROPOSED": "closed", "PRICE_DISPUTED": "closed", "RESOLVED": "settled", "REMOVED": "closed", } -"""The venue's market lifecycle, as this library's status. A proposed or -disputed price is the outcome being decided: trading has stopped, nothing is -paid yet.""" +# This library's sort keys the venue can order markets by. It orders +# categories by publication, markets by volume only. SORT_BY = {"volume": "VOLUME_24H_DESC"} -"""This library's sort keys the venue can order markets by. It orders -categories by publication, markets by volume only.""" +# The venue's sample widths. A timeframe between them is built from the +# widest one that divides it. CANDLE_RESOLUTIONS = {60: "1m", 300: "5m", 3600: "1h", 86400: "1d"} -"""The venue's sample widths. A timeframe between them is built from the -widest one that divides it.""" DEFAULT_BARS = 100 +# Most venue pages one catalog call reads while filling `limit` after filtering. MAX_PAGES = 10 -"""Most venue pages one catalog call reads while filling `limit` after filtering.""" def api_key_from(api_key: str | None) -> str | None: diff --git a/synpath/server/api.py b/synpath/server/api.py index 99e0350..e37a5bb 100644 --- a/synpath/server/api.py +++ b/synpath/server/api.py @@ -67,6 +67,12 @@ log = logging.getLogger("synpath.server") +# Library exception to status code. +# +# The two that are worth explaining: `NotSupported` is 501 rather than 404, +# because the venue has no such capability at all and no id would have worked; +# and a venue failing us is 502, not 500, because this service is fine and is +# reporting on an upstream that is not. STATUS_FOR: dict[type[Exception], int] = { MarketNotFound: 404, BadRequest: 400, @@ -77,13 +83,6 @@ NetworkError: 502, ExchangeError: 502, } -"""Library exception to status code. - -The two that are worth explaining: `NotSupported` is 501 rather than 404, -because the venue has no such capability at all and no id would have worked; -and a venue failing us is 502, not 500, because this service is fine and is -reporting on an upstream that is not. -""" def takes_keyword(function: Any, name: str) -> bool: @@ -156,9 +155,9 @@ def close(self) -> None: self._exchanges.clear() +# Used when a request arrives on an app that carries no registry of its own — +# which is what happens when the router is mounted on somebody else's app. DEFAULT_REGISTRY = VenueRegistry() -"""Used when a request arrives on an app that carries no registry of its own — -which is what happens when the router is mounted on somebody else's app.""" def get_venue( @@ -182,15 +181,15 @@ def get_venue( return registry.get(venue) +# Module level on purpose: `from __future__ import annotations` turns every +# annotation into a string, and FastAPI resolves those against module globals. +# Defined inside the factory it would be invisible and the app would fail to +# build its schema. Venue = Annotated[Exchange, Depends(get_venue)] -"""Module level on purpose: `from __future__ import annotations` turns every -annotation into a string, and FastAPI resolves those against module globals. -Defined inside the factory it would be invisible and the app would fail to -build its schema.""" +# Bars one candle read may ask for: two weeks of minutes. A chart reaching back +# over a quiet market asks for many periods to find enough bars that traded. MAX_CANDLES = 20_000 -"""Bars one candle read may ask for: two weeks of minutes. A chart reaching back -over a quiet market asks for many periods to find enough bars that traded.""" RESPONSES: dict[int | str, dict[str, Any]] = { 400: {"model": ErrorBody, "description": "Invalid parameters."}, diff --git a/synpath/server/batch.py b/synpath/server/batch.py index db9032d..12df625 100644 --- a/synpath/server/batch.py +++ b/synpath/server/batch.py @@ -32,10 +32,10 @@ from .errors import code_of, http_error from .models import ErrorDetail, PageResponse +# Ids one batch call may name. The terminal asks for at most 60 at a time; a +# caller with more splits them, so one request never holds a venue's whole rate +# budget. MAX_BATCH_IDS = 100 -"""Ids one batch call may name. The terminal asks for at most 60 at a time; a -caller with more splits them, so one request never holds a venue's whole rate -budget.""" MAX_SEARCH_LIMIT = 100 diff --git a/synpath/server/bucket_books.py b/synpath/server/bucket_books.py index 5a47879..06f3317 100644 --- a/synpath/server/bucket_books.py +++ b/synpath/server/bucket_books.py @@ -43,16 +43,16 @@ ZERO = Decimal("0") +# How long a member's fee schedule is trusted before it is read again. FEE_SCHEDULE_TTL_S = 600.0 -"""How long a member's fee schedule is trusted before it is read again.""" +# How long a member whose schedule could not be read counts as free before +# it is asked again. FEE_SCHEDULE_RETRY_S = 60.0 -"""How long a member whose schedule could not be read counts as free before -it is asked again.""" +# Where a member's book came from: `stream` (serve's live venue stream), +# `rest` (read from the venue for this request) or `missing`. BookSource = str -"""Where a member's book came from: `stream` (serve's live venue stream), -`rest` (read from the venue for this request) or `missing`.""" def _text(value: Decimal | None) -> str | None: @@ -65,8 +65,8 @@ def _text(value: Decimal | None) -> str | None: @dataclass class _Schedule: + # The member's `FeeSchedule`, or `None` where it could not be read. schedule: Any | None - """The member's `FeeSchedule`, or `None` where it could not be read.""" read_at: float @@ -183,8 +183,8 @@ class MemberState: """What a bucket subscription knows about one member beyond its book.""" market_id: str + # Why serve cannot stream this member (`credentials_required`, …), if it cannot. error: str | None = None - """Why serve cannot stream this member (`credentials_required`, …), if it cannot.""" def merged_levels(bucket: Bucket, views: dict[str, router.BookView], fees: BucketFees, diff --git a/synpath/server/combo_warmer.py b/synpath/server/combo_warmer.py index 5942933..29e0192 100644 --- a/synpath/server/combo_warmer.py +++ b/synpath/server/combo_warmer.py @@ -54,8 +54,8 @@ REFRESH_INTERVAL_S = 60.0 PAUSE_S = 0.1 +# The GIL switch interval while boards are warmed (`loop`). SWITCH_INTERVAL_S = 0.0005 -"""The GIL switch interval while boards are warmed (`loop`).""" @dataclass @@ -63,13 +63,13 @@ class WarmBoard: """One venue's board as the warmer holds it.""" board: ComboBoard | None = None + # When the board held was built, ms. built_at: int | None = None - """When the board held was built, ms.""" + # How long that build took. build_ms: int | None = None - """How long that build took.""" refreshing: bool = False + # Why the last build failed, while the board held is an older one (or none). error: str | None = None - """Why the last build failed, while the board held is an older one (or none).""" @property def status(self) -> str: @@ -87,11 +87,11 @@ def __init__(self, engine: Any, registry: Any | None = None, *, refresh_interval self.pause_s = float(pause_s) self.clock = clock self.boards: dict[str, WarmBoard] = {} + # Per venue, `market_labels` of its last board. self.labels: dict[str, dict[str, str]] = {} - """Per venue, `market_labels` of its last board.""" self._builds: dict[str, asyncio.Task] = {} + # Per venue, what a build keeps for the next (Gamma's markets and events). self.caches: dict[str, BoardCache] = {} - """Per venue, what a build keeps for the next (Gamma's markets and events).""" # -- which venues ----------------------------------------------------------- diff --git a/synpath/server/market_stream.py b/synpath/server/market_stream.py index 3ab9c7b..0b40ad1 100644 --- a/synpath/server/market_stream.py +++ b/synpath/server/market_stream.py @@ -51,18 +51,18 @@ log = logging.getLogger("synpath.server") +# A client's channel name, and the stream capability that carries it. CHANNELS: dict[str, str] = {"book": "watch_order_book", "trades": "watch_trades", "quotes": "watch_ticker"} -"""A client's channel name, and the stream capability that carries it.""" +# Messages a client may fall behind by before its socket is closed (1013). OUTBOX_LIMIT = 10_000 -"""Messages a client may fall behind by before its socket is closed (1013).""" CLOSE_UNAUTHORIZED = 4401 CLOSE_TOO_SLOW = 1013 Side = Literal["yes", "no"] +# Builds a venue's market stream, or returns `None` where it cannot (no credentials). StreamFactory = Callable[[str], Any] -"""Builds a venue's market stream, or returns `None` where it cannot (no credentials).""" class SubscribeRefused(Exception): @@ -155,8 +155,8 @@ class Session: send interval, not one per venue update.""" def __init__(self, principal: Any | None = None) -> None: + # Who the client's access token belongs to; bucket visibility is checked against it. self.principal = principal - """Who the client's access token belongs to; bucket visibility is checked against it.""" self.subscriptions: dict[str, Subscription] = {} self.outbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=OUTBOX_LIMIT) self.dirty_books: set[str] = set() @@ -293,12 +293,12 @@ def __init__(self, open_stream: StreamFactory, *, feeds: Any | None = None, enab self.venue_locks: dict[str, asyncio.Lock] = {} self.closing = False self.releasing: set[asyncio.Task] = set() + # `synpath.server.bucket_books.BucketBooks`, where the server holds buckets. self.buckets: Any | None = None - """`synpath.server.bucket_books.BucketBooks`, where the server holds buckets.""" + # Bucket id -> every client subscription streaming it, for a definition change. self.bucket_subscriptions: dict[str, set[BucketSubscription]] = {} - """Bucket id -> every client subscription streaming it, for a definition change.""" + # Venue -> the last lifecycle event its market connection reported, for `venue_status`. self.last_stream_status: dict[str, StreamStatusEvent] = {} - """Venue -> the last lifecycle event its market connection reported, for `venue_status`.""" if feeds is not None: self.adopt_engine_streams(feeds) diff --git a/synpath/server/rfq_routes.py b/synpath/server/rfq_routes.py index 7227297..209a24b 100644 --- a/synpath/server/rfq_routes.py +++ b/synpath/server/rfq_routes.py @@ -228,15 +228,15 @@ async def quote_result(engine: Engine, quote: RfqQuote, key: str, venue: str) -> order_id=(intent.order_id if intent else None) or None) +# How long a combo list read (eligible markets, collections) is served from +# memory: the terminal polls them, and they change on the scale of minutes. COMBO_LIST_TTL_S = 15.0 -"""How long a combo list read (eligible markets, collections) is served from -memory: the terminal polls them, and they change on the scale of minutes.""" COMBO_LIST_CACHE_SIZE = 256 +# How long a venue's combo board is served from memory: it reads several +# pages of the venue's list (and, on Kalshi, every collection event's markets). COMBO_BOARD_TTL_S = 30.0 -"""How long a venue's combo board is served from memory: it reads several -pages of the venue's list (and, on Kalshi, every collection event's markets).""" +# How long a group preview is reused: the slip asks again on every pick. PREVIEW_TTL_S = 10.0 -"""How long a group preview is reused: the slip asks again on every pick.""" class TtlCache: diff --git a/synpath/server/serve.py b/synpath/server/serve.py index 738ccca..aecc6bc 100644 --- a/synpath/server/serve.py +++ b/synpath/server/serve.py @@ -62,10 +62,10 @@ def read_options(market_data: dict[str, Any]) -> dict[str, dict[str, Any]]: return {venue: {"api_key": key.api_key} for venue, key in market_data.items() if venue in READ_API_KEY_VENUES} +# Venues whose read adapter takes an API key: predict.fun's mainnet refuses +# every request without one; Opinion's raises its rate limit with one; SX Bet +# documents its book snapshots as keyed. READ_API_KEY_VENUES = ("opinion", "predict_fun", "sx_bet") -"""Venues whose read adapter takes an API key: predict.fun's mainnet refuses -every request without one; Opinion's raises its rate limit with one; SX Bet -documents its book snapshots as keyed.""" @dataclass @@ -77,16 +77,16 @@ class Stack: reconciler: Any eod: Any config: dict[str, Any] + # The owner access token created on this start; `None` if the control database + # already had users. owner_key: str | None = None - """The owner access token created on this start; `None` if the control database - already had users.""" + # The access token on record in the local registry for this server's address. local_key: str | None = None - """The access token on record in the local registry for this server's address.""" extra_tasks: dict[str, Any] = field(default_factory=dict) + # The `/ws/market` hub: venue market connections shared with the feeds. market_streams: Any | None = None - """The `/ws/market` hub: venue market connections shared with the feeds.""" + # Every combo venue's board, kept warm (`synpath.server.combo_warmer`); None with `[combo_board] warm = false`. combo_boards: Any | None = None - """Every combo venue's board, kept warm (`synpath.server.combo_warmer`); None with `[combo_board] warm = false`.""" async def build( diff --git a/synpath/server/trading.py b/synpath/server/trading.py index bc3a627..64abf57 100644 --- a/synpath/server/trading.py +++ b/synpath/server/trading.py @@ -66,6 +66,18 @@ log = logging.getLogger("synpath.server") +# Library exception to status code on the trading routes, most specific +# class first. A venue refusing our own credentials is 502: the caller's token +# was fine, the server's venue key was not. +# +# An order whose outcome is not known yet is 202, not a 5xx: the instruction +# is recorded and the send may have happened, so it has not failed. The body +# is the usual error envelope (`submission_in_doubt` for this send, +# `submission_pending` for a retry while the first send is still being +# reconciled), with `retryable: true` and the `client_order_id` to retry +# under; the same id never sends twice. A cancel the venue neither performed +# nor recognises is 409 `cancel_unconfirmed`: the order exists here, and its +# last observed status stands. TRADING_STATUS: dict[type[Exception], int] = { SubmissionInDoubt: 202, SubmissionPending: 202, @@ -86,18 +98,6 @@ AuthenticationError: 502, ExchangeError: 502, } -"""Library exception to status code on the trading routes, most specific -class first. A venue refusing our own credentials is 502: the caller's token -was fine, the server's venue key was not. - -An order whose outcome is not known yet is 202, not a 5xx: the instruction -is recorded and the send may have happened, so it has not failed. The body -is the usual error envelope (`submission_in_doubt` for this send, -`submission_pending` for a retry while the first send is still being -reconciled), with `retryable: true` and the `client_order_id` to retry -under; the same id never sends twice. A cancel the venue neither performed -nor recognises is 409 `cancel_unconfirmed`: the order exists here, and its -last observed status stands.""" DEFAULT_ORDERS_PAGE = 100 diff --git a/synpath/smarkets.py b/synpath/smarkets.py index d1698f9..2d0eb2d 100644 --- a/synpath/smarkets.py +++ b/synpath/smarkets.py @@ -75,23 +75,23 @@ API_URL = "https://api.smarkets.com" CURRENCY = "GBP" +# Prices are basis points of 100%: 5000 is 50%. PRICE_SCALE = 10_000 -"""Prices are basis points of 100%: 5000 is 50%.""" +# Quantities are 1/100 of a penny: 10,000 is £1 of pot, one contract. QUANTITY_SCALE = 10_000 -"""Quantities are 1/100 of a penny: 10,000 is £1 of pot, one contract.""" +# Smarkets allows 1200 requests per 60 s on an account (API users) and less +# anonymously: an anonymous `/v3/events/` answers `X-RateLimit-Limit: 60`. One a +# second, shared process-wide, stays inside the anonymous budget. LIMITER = RateLimiter(1.0, burst=5) -"""Smarkets allows 1200 requests per 60 s on an account (API users) and less -anonymously: an anonymous `/v3/events/` answers `X-RateLimit-Limit: 60`. One a -second, shared process-wide, stays inside the anonymous budget.""" +# `/quotes/` has its own, stricter limit: 50 per 60 s for API users and, as +# observed anonymously, 20 per 60 s. Shared by the adapter and the stream. QUOTES_LIMITER = RateLimiter(20 / 60, burst=3) -"""`/quotes/` has its own, stricter limit: 50 per 60 s for API users and, as -observed anonymously, 20 per 60 s. Shared by the adapter and the stream.""" +# A session token lasts 30 minutes from its last authenticated request; one +# unused for longer than this is replaced by a fresh login. SESSION_IDLE_LIMIT = 25 * 60.0 -"""A session token lasts 30 minutes from its last authenticated request; one -unused for longer than this is replaced by a fresh login.""" # Batch sizes, as the OpenAPI document caps each route's ids. EVENTS_PER_REQUEST = 300 @@ -103,35 +103,35 @@ LAST_PRICE_MARKETS_PER_REQUEST = 100 TRADE_CONTRACTS_PER_REQUEST = 300 +# Smarkets events read per listing request. Football fixtures hold about a +# hundred markets each, so a page of contracts rarely needs more. EVENT_PAGE = 20 -"""Smarkets events read per listing request. Football fixtures hold about a -hundred markets each, so a page of contracts rarely needs more.""" +# Most event pages one listing call reads while filling a page. MAX_PAGES = 20 -"""Most event pages one listing call reads while filling a page.""" +# Event scopes listed by default: the kinds that hold markets. Roots and +# categories (Football, Premier League) are tree nodes without markets. DEFAULT_SCOPES = ("single_event", "outright", "antepost") -"""Event scopes listed by default: the kinds that hold markets. Roots and -categories (Football, Premier League) are tree nodes without markets.""" +# Smarkets event states for each status a listing asks for. STATES = { "open": ("upcoming", "live"), "closed": ("ended", "suspended"), "settled": ("settled", "cancelled"), "all": ("new", "upcoming", "live", "ended", "settled", "cancelled", "suspended"), } -"""Smarkets event states for each status a listing asks for.""" +# Smarkets' standard commission on net market winnings. DEFAULT_COMMISSION = 0.02 -"""Smarkets' standard commission on net market winnings.""" +# A contract's outcome as the YES result. A reduced contract was withdrawn +# (a non-runner) and its bets voided, the matched ones on other contracts +# reduced by its factor. A dead heat pays the winning stakes divided by the +# number of contracts tied, so it is neither yes nor no. CONTRACT_RESULTS: dict[str, str] = { "winner": "yes", "loser": "no", "voided": "void", "reduced": "void", "deadheat": "dead_heat", } -"""A contract's outcome as the YES result. A reduced contract was withdrawn -(a non-runner) and its bets voided, the matched ones on other contracts -reduced by its factor. A dead heat pays the winning stakes divided by the -number of contracts tied, so it is neither yes nor no.""" USER_AGENT = "synpath (+https://github.com/Synpath-ai/synpath)" diff --git a/synpath/sx_bet.py b/synpath/sx_bet.py index 703aef7..11949ea 100644 --- a/synpath/sx_bet.py +++ b/synpath/sx_bet.py @@ -49,34 +49,35 @@ TESTNET_API_URL = "https://api.toronto.sx.bet" VENUE = "sx_bet" +# The market routes share 500 requests a minute per IP; kept under it, +# shared process-wide. LIMITER = RateLimiter(6.0, burst=10) -"""The market routes share 500 requests a minute per IP; kept under it, -shared process-wide.""" +# Rows per listing page: the venue's maximum. PAGE = 100 -"""Rows per listing page: the venue's maximum.""" +# Most listing pages one walk reads: 20,000 markets, against about 12,000 today. MAX_PAGES = 200 -"""Most listing pages one walk reads: 20,000 markets, against about 12,000 today.""" +# Prices are implied probabilities scaled by 1e20. ODDS_SCALE = 10 ** 20 -"""Prices are implied probabilities scaled by 1e20.""" +# Stakes and returns are USDC in 6-decimal base units. USDC = 1_000_000 -"""Stakes and returns are USDC in 6-decimal base units.""" +# The odds ladder steps by 0.125%. TICK = 0.00125 -"""The odds ladder steps by 0.125%.""" +# 1% of the profit on a winning bet, for the taker; a maker pays nothing. TAKER_FEE = 0.01 -"""1% of the profit on a winning bet, for the taker; a maker pays nothing.""" +# Hashes one `/markets/find` request takes. MAX_FIND = 30 -"""Hashes one `/markets/find` request takes.""" +# Most tape pages one candle read walks back through: 2,000 trades. MAX_TRADE_PAGES = 20 -"""Most tape pages one candle read walks back through: 2,000 trades.""" +# The venue's market types by id, as its reference names them. MARKET_TYPES: dict[int, str] = { 1: "1X2", 2: "Under/Over", 3: "Asian Handicap", 17: "Both Teams To Score", 21: "Under/Over First Period", 28: "Under/Over Including Overtime", 29: "Under/Over Rounds", @@ -92,14 +93,13 @@ 835: "Asian Under/Over", 866: "Set Spread", 995: "Anytime Touchdown Scorer", 1536: "Under/Over Maps", 1618: "1st 5 Innings Winner-12", } -"""The venue's market types by id, as its reference names them.""" +# A market hash. The venue refuses a whole lookup over one malformed hash, +# so they are checked here first. HASH = re.compile(r"0x[0-9a-f]{64}") -"""A market hash. The venue refuses a whole lookup over one malformed hash, -so they are checked here first.""" +# A settled market's reported outcome: side one, side two, or void. WINNERS = {1: "yes", 2: "no", 0: "void"} -"""A settled market's reported outcome: side one, side two, or void.""" def odds(value: Any) -> float | None: diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 3037377..bc7aa06 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -68,10 +68,10 @@ def _redacted_repr(self: Any) -> str: @dataclass(frozen=True, repr=False) class KalshiCredentials: key_id: str + # RSA private key, PEM. Signs `timestamp + method + path` per request. private_key_pem: bytes = field(repr=False) - """RSA private key, PEM. Signs `timestamp + method + path` per request.""" + # `prod` trades on Kalshi itself; `demo` on its practice exchange. env: Literal["demo", "prod"] = "prod" - """`prod` trades on Kalshi itself; `demo` on its practice exchange.""" _public = ("key_id", "env") __repr__ = _redacted_repr @@ -80,41 +80,41 @@ def secrets(self) -> list[str]: return [self.private_key_pem.decode(errors="ignore")] +# Synpath's Polymarket builder code, attached to orders unless the account +# sets its own or opts out (`POLYMARKET_BUILDER_CODE=none`). Its builder fee +# rate is 0: it adds nothing to what an order costs. SYNPATH_BUILDER_CODE = "0x3373b88f438d41079965d0742b16c602aa932cbc3b7ee589db36cbf85778c869" -"""Synpath's Polymarket builder code, attached to orders unless the account -sets its own or opts out (`POLYMARKET_BUILDER_CODE=none`). Its builder fee -rate is 0: it adds nothing to what an order costs.""" BUILDER_CODE = re.compile(r"^0x[0-9a-fA-F]{64}$") @dataclass(frozen=True, repr=False) class PolymarketCredentials: + # The signer's private key, hex. Signs every order (EIP-712) and, when + # no API credentials are given, derives them. private_key: str = field(repr=False) - """The signer's private key, hex. Signs every order (EIP-712) and, when - no API credentials are given, derives them.""" + # Which wallet the orders spend from: 0 an EOA (allowlisted accounts + # only), 1 a legacy Magic/Google proxy wallet, 2 a legacy Safe, 3 a + # Deposit Wallet -- the default for every account created since + # 2026-05-04. signature_type: int = 0 - """Which wallet the orders spend from: 0 an EOA (allowlisted accounts - only), 1 a legacy Magic/Google proxy wallet, 2 a legacy Safe, 3 a - Deposit Wallet -- the default for every account created since - 2026-05-04.""" + # The account wallet that holds the pUSD and tokens. Required for types + # 1-3; for an EOA it is the signer's own address. funder: str | None = None - """The account wallet that holds the pUSD and tokens. Required for types - 1-3; for an EOA it is the signer's own address.""" api_key: str | None = None api_secret: str | None = field(default=None, repr=False) + # CLOB API credentials, when already created. Derived from the wallet + # key on first use otherwise. api_passphrase: str | None = field(default=None, repr=False) - """CLOB API credentials, when already created. Derived from the wallet - key on first use otherwise.""" + # A bytes32 builder code attached to every order for attribution. Public, + # not a secret. Synpath's by default; None attaches none. builder_code: str | None = SYNPATH_BUILDER_CODE - """A bytes32 builder code attached to every order for attribution. Public, - not a secret. Synpath's by default; None attaches none.""" relayer_api_key: str | None = field(default=None, repr=False) + # A Relayer API key (polymarket.com -> Settings -> API Keys) for gasless + # wallet transactions: approvals, split, merge, redeem. relayer_api_key_address: str | None = None - """A Relayer API key (polymarket.com -> Settings -> API Keys) for gasless - wallet transactions: approvals, split, merge, redeem.""" + # A Polygon JSON-RPC endpoint, for an EOA's own on-chain transactions. rpc_url: str | None = None - """A Polygon JSON-RPC endpoint, for an EOA's own on-chain transactions.""" _public = ("signature_type", "funder", "api_key", "builder_code", "relayer_api_key_address") __repr__ = _redacted_repr @@ -129,9 +129,9 @@ class PolymarketUSCredentials: after identity verification in the app.""" key_id: str + # Base64 Ed25519 private key, shown once at creation. Signs + # `timestamp + METHOD + path` on every request. secret_key: str = field(repr=False) - """Base64 Ed25519 private key, shown once at creation. Signs - `timestamp + METHOD + path` on every request.""" _public = ("key_id",) __repr__ = _redacted_repr @@ -147,14 +147,14 @@ class PolymarketUSExchangeCredentials: participant id per user.""" client_id: str + # RSA private key, PEM. Signs the client-assertion JWT exchanged for a + # three-minute access token. private_key_pem: bytes = field(repr=False) - """RSA private key, PEM. Signs the client-assertion JWT exchanged for a - three-minute access token.""" + # `firms//users/`, exactly as issued at onboarding. participant_id: str - """`firms//users/`, exactly as issued at onboarding.""" + # The trading account orders are placed on. The first account the user + # may trade when not given. account: str | None = None - """The trading account orders are placed on. The first account the user - may trade when not given.""" env: Literal["preprod", "prod"] = "preprod" _public = ("client_id", "participant_id", "account", "env") __repr__ = _redacted_repr @@ -168,15 +168,15 @@ def secrets(self) -> list[str]: class OpinionCredentials: """Opinion: the wallet that signs orders, and the account's API key.""" + # The signer's private key, hex: the wallet connected on opinion.trade. + # Signs every order (EIP-712) for the account's Safe. private_key: str = field(repr=False) - """The signer's private key, hex: the wallet connected on opinion.trade. - Signs every order (EIP-712) for the account's Safe.""" + # The Open API key, from `POST /auth/api-key` signed by the same wallet, + # or the venue's application form. api_key: str = field(repr=False) - """The Open API key, from `POST /auth/api-key` signed by the same wallet, - or the venue's application form.""" + # The account's Safe, which holds the USDT and tokens ("MyProfile" on + # opinion.trade). Read from the venue on first use when not given. multisig_address: str | None = None - """The account's Safe, which holds the USDT and tokens ("MyProfile" on - opinion.trade). Read from the venue on first use when not given.""" _public = ("multisig_address",) __repr__ = _redacted_repr @@ -189,15 +189,15 @@ def secrets(self) -> list[str]: class HyperliquidCredentials: """Hyperliquid: the key that signs, and the account it signs for.""" + # The signer's private key, hex: the account's own wallet, or an API + # wallet the account approved on app.hyperliquid.xyz (More -> API), which + # can trade but never withdraw. private_key: str = field(repr=False) - """The signer's private key, hex: the account's own wallet, or an API - wallet the account approved on app.hyperliquid.xyz (More -> API), which - can trade but never withdraw.""" + # The account the orders are for. Needed when `private_key` is an API + # wallet; with the account's own key it is that key's address. account_address: str | None = None - """The account the orders are for. Needed when `private_key` is an API - wallet; with the account's own key it is that key's address.""" + # Sign for and talk to the test network, where outcomes trade for test funds. testnet: bool = False - """Sign for and talk to the test network, where outcomes trade for test funds.""" _public = ("account_address", "testnet") __repr__ = _redacted_repr @@ -220,19 +220,19 @@ class PredictFunCredentials: """predict.fun: the key that signs, the account it signs for, and the API key mainnet needs.""" + # The signer's private key, hex: a plain wallet's own, or, for a + # Predict account, its owner key (the Privy wallet, exported from + # predict.fun's account settings). private_key: str = field(repr=False) - """The signer's private key, hex: a plain wallet's own, or, for a - Predict account, its owner key (the Privy wallet, exported from - predict.fun's account settings).""" + # The mainnet API key (developers.predict.fun). The test network needs none. api_key: str | None = field(default=None, repr=False) - """The mainnet API key (developers.predict.fun). The test network needs none.""" + # The Predict account (the deposit address) the key signs for. Unset, + # the key's own wallet trades. account_address: str | None = None - """The Predict account (the deposit address) the key signs for. Unset, - the key's own wallet trades.""" + # Trade on BNB Chain's test network, for test USDT. testnet: bool = False - """Trade on BNB Chain's test network, for test USDT.""" + # A BNB Chain node for the balance read. A public one when unset. rpc_url: str | None = None - """A BNB Chain node for the balance read. A public one when unset.""" _public = ("account_address", "testnet", "rpc_url") __repr__ = _redacted_repr @@ -255,15 +255,15 @@ class LimitlessCredentials: """Limitless: the wallet that signs orders, and the scoped API token that signs every request.""" + # The wallet's private key, hex: the wallet connected on limitless.exchange, + # in EOA trading mode. Signs every order (EIP-712). private_key: str = field(repr=False) - """The wallet's private key, hex: the wallet connected on limitless.exchange, - in EOA trading mode. Signs every order (EIP-712).""" + # The scoped API token's id (limitless.exchange, profile, API tokens, Derive). token_id: str - """The scoped API token's id (limitless.exchange, profile, API tokens, Derive).""" + # The token's secret, base64, shown once when the token is derived. secret: str = field(repr=False) - """The token's secret, base64, shown once when the token is derived.""" + # A Base node for the balance read. A public one when unset. rpc_url: str | None = None - """A Base node for the balance read. A public one when unset.""" _public = ("token_id", "rpc_url") __repr__ = _redacted_repr @@ -287,17 +287,17 @@ class SxBetCredentials: account's orders, fills, positions and balance, and every cancel. Only a new order is also an EIP-712 message signed by the wallet.""" + # The SX Bet API key, generated on sx.bet under Account -> Overview -> + # API Credentials. It belongs to the account's own address. api_key: str = field(repr=False) - """The SX Bet API key, generated on sx.bet under Account -> Overview -> - API Credentials. It belongs to the account's own address.""" + # The wallet's private key, hex: the account's own address, never the + # proxy wallet. Signs every order (EIP-712); unset, orders cannot be placed. private_key: str | None = field(default=None, repr=False) - """The wallet's private key, hex: the account's own address, never the - proxy wallet. Signs every order (EIP-712); unset, orders cannot be placed.""" + # The account's wallet address, for the account stream's channels when + # no private key names it. Public: the 0x address, not the key. account_address: str | None = None - """The account's wallet address, for the account stream's channels when - no private key names it. Public: the 0x address, not the key.""" + # The Toronto test network (api.toronto.sx.bet) instead of mainnet. testnet: bool = False - """The Toronto test network (api.toronto.sx.bet) instead of mainnet.""" _public = ("account_address", "testnet") __repr__ = _redacted_repr @@ -322,17 +322,17 @@ class BetfairCredentials: call, market data included, needs both: the key names the application, the login's session token the account.""" + # The application key (delayed or live) from the account's developer + # settings. Sent on every request as `X-Application`. app_key: str = field(repr=False) - """The application key (delayed or live) from the account's developer - settings. Sent on every request as `X-Application`.""" username: str password: str = field(repr=False) cert_path: str | None = None + # A self-signed client certificate and its key, for Betfair's + # non-interactive (certificate) login. Without them the login is interactive. key_path: str | None = None - """A self-signed client certificate and its key, for Betfair's - non-interactive (certificate) login. Without them the login is interactive.""" + # The Betfair site the account is registered on: com, it, es, ro or com.au. jurisdiction: str = "com" - """The Betfair site the account is registered on: com, it, es, ro or com.au.""" _public = ("username", "cert_path", "jurisdiction") __repr__ = _redacted_repr @@ -363,6 +363,8 @@ def secrets(self) -> list[str]: | BetfairCredentials | SmarketsCredentials ) +# What each venue reads. The first entry is the one whose absence means +# "not configured"; the rest are optional or have defaults. ENV_NAMES: dict[str, tuple[str, ...]] = { "kalshi": ("KALSHI_KEY_ID", "KALSHI_PRIVATE_KEY_PATH", "KALSHI_ENV"), "polymarket": ( @@ -390,8 +392,6 @@ def secrets(self) -> list[str]: ), "smarkets": ("SMARKETS_USERNAME", "SMARKETS_PASSWORD"), } -"""What each venue reads. The first entry is the one whose absence means -"not configured"; the rest are optional or have defaults.""" def read_dotenv(path: Path | str) -> dict[str, str]: @@ -750,16 +750,16 @@ def secrets(self) -> list[str]: return [self.api_key] +# The venues whose market data needs an API key but no private key: the +# variable holding the key, and the one choosing the test network. Kalshi and +# Polymarket US sign even their market streams with the full key pair, so +# their trading credentials are their market-data credentials; Polymarket, +# Hyperliquid and Limitless serve market data to anyone. MARKET_DATA_KEYS: dict[str, tuple[str, str | None]] = { "opinion": ("OPINION_API_KEY", None), "predict_fun": ("PREDICT_FUN_API_KEY", "PREDICT_FUN_TESTNET"), "sx_bet": ("SX_BET_API_KEY", "SX_BET_TESTNET"), } -"""The venues whose market data needs an API key but no private key: the -variable holding the key, and the one choosing the test network. Kalshi and -Polymarket US sign even their market streams with the full key pair, so -their trading credentials are their market-data credentials; Polymarket, -Hyperliquid and Limitless serve market data to anyone.""" def _truthy(value: str | None) -> bool: diff --git a/synpath/trading/hyperliquid.py b/synpath/trading/hyperliquid.py index 7623149..b13d1e3 100644 --- a/synpath/trading/hyperliquid.py +++ b/synpath/trading/hyperliquid.py @@ -50,23 +50,23 @@ MAINNET_URL = "https://api.hyperliquid.xyz" TESTNET_URL = "https://api.hyperliquid-testnet.xyz" +# An outcome coin's asset id is this plus its number (`#75440` -> 100_075_440). OUTCOME_ASSET_OFFSET = 100_000_000 -"""An outcome coin's asset id is this plus its number (`#75440` -> 100_075_440).""" +# The venue's minimum order value, in the quote token, counted on the token bought. MIN_NOTIONAL = Decimal("10") -"""The venue's minimum order value, in the quote token, counted on the token bought.""" +# Significant figures a price may have. MAX_SIGNIFICANT = 5 -"""Significant figures a price may have.""" QUOTE = "USDC" +# Fill directions that are trades. The others (`Split Outcome`, `Merge +# Outcome`, `Settlement`) move quote and outcome tokens without one. TRADE_DIRECTIONS = frozenset({"Buy", "Sell"}) -"""Fill directions that are trades. The others (`Split Outcome`, `Merge -Outcome`, `Settlement`) move quote and outcome tokens without one.""" +# The venue's `tif`. `Alo` (add liquidity only) is a post-only GTC. TIME_IN_FORCE = {"Gtc": TimeInForce.GTC, "Ioc": TimeInForce.IOC, "Alo": TimeInForce.GTC, "FrontendMarket": TimeInForce.IOC} -"""The venue's `tif`. `Alo` (add liquidity only) is a post-only GTC.""" def D(value: Any) -> Decimal: @@ -362,8 +362,8 @@ def __init__( self.credentials = credentials self.signer = WalletSigner(credentials.private_key) + # The account whose orders, fills and balances these are. self.address = credentials.address - """The account whose orders, fills and balances these are.""" self.mainnet = not credentials.testnet self.account = Account(venue=VENUE, name=account_name) # 1200 request weight a minute per IP; an action weighs 1, most info reads 20. @@ -375,8 +375,8 @@ def __init__( from ..hyperliquid import Hyperliquid catalog = Hyperliquid(testnet=not self.mainnet) + # The read adapter: fee scales for estimates. self.catalog = catalog - """The read adapter: fee scales for estimates.""" self._last_nonce = 0 self._nonce_lock = asyncio.Lock() diff --git a/synpath/trading/hyperliquid_signing.py b/synpath/trading/hyperliquid_signing.py index 759dbab..1d0ff6f 100644 --- a/synpath/trading/hyperliquid_signing.py +++ b/synpath/trading/hyperliquid_signing.py @@ -138,11 +138,11 @@ def _type_hash(signature: str) -> bytes: return keccak(text=signature) +# The `Exchange` domain every L1 action is signed in: chain id 1337, no contract. DOMAIN_SEPARATOR = keccak( _type_hash("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") + keccak(text="Exchange") + keccak(text="1") + (1337).to_bytes(32, "big") + bytes(32) ) -"""The `Exchange` domain every L1 action is signed in: chain id 1337, no contract.""" AGENT_TYPE_HASH = _type_hash("Agent(string source,bytes32 connectionId)") diff --git a/synpath/trading/init.py b/synpath/trading/init.py index 8753ae2..34c91ee 100644 --- a/synpath/trading/init.py +++ b/synpath/trading/init.py @@ -278,6 +278,8 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: return {"SMARKETS_USERNAME": username, "SMARKETS_PASSWORD": password} +# Each venue, the settings any of which means it is set up already (an API +# key alone is, for market data), and the questions. VENUES: list[tuple[str, tuple[str, ...], Callable[[Prompter], dict[str, str]]]] = [ ("Kalshi", ("KALSHI_KEY_ID",), ask_kalshi), ("Polymarket", ("POLYMARKET_PRIVATE_KEY",), ask_polymarket), @@ -290,8 +292,6 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: ("Betfair", ("BETFAIR_APP_KEY",), ask_betfair), ("Smarkets (optional login; market data needs none)", ("SMARKETS_USERNAME",), ask_smarkets), ] -"""Each venue, the settings any of which means it is set up already (an API -key alone is, for market data), and the questions.""" def _quote(value: str) -> str: diff --git a/synpath/trading/instruments.py b/synpath/trading/instruments.py index cffa79c..a5ab0c2 100644 --- a/synpath/trading/instruments.py +++ b/synpath/trading/instruments.py @@ -73,23 +73,23 @@ class MarketSpec(BaseModel): model_config = ConfigDict(extra="forbid") + # Synpath id, `venue:native`. market_id: str - """Synpath id, `venue:native`.""" venue: str precision: Precision + # Accepting orders right now, as far as the catalog knew. tradable: bool - """Accepting orders right now, as far as the catalog knew.""" tradable_yes: bool = True + # Per side, where a venue says (Polymarket US). Both true elsewhere. tradable_no: bool = True - """Per side, where a venue says (Polymarket US). Both true elsewhere.""" status: str close_at: int | None = None resolution_at: int | None = None + # A per-market cap the venue imposes, where it publishes one. position_limit: Decimal | None = None - """A per-market cap the venue imposes, where it publishes one.""" yes_token: str | None = None + # The venue's own ids for the two sides, where it has them (Polymarket). no_token: str | None = None - """The venue's own ids for the two sides, where it has them (Polymarket).""" read_at: int = Field(default_factory=lambda: int(time.time() * 1000)) info: dict[str, Any] = Field(default_factory=dict) @@ -98,8 +98,8 @@ def age_s(self, now_ms: int | None = None) -> float: return max(0.0, (now - self.read_at) / 1000) +# The old name, kept so an import written against it still resolves. InstrumentSpec = MarketSpec -"""The old name, kept so an import written against it still resolves.""" def precision_for(market: Market) -> Precision: @@ -150,8 +150,8 @@ def spec_from_market(market: Market) -> MarketSpec: ) +# `(venue, market_ids) -> markets`, however the caller reaches the venue. Loader = Callable[[str, list[str]], Awaitable[list[Market]]] -"""`(venue, market_ids) -> markets`, however the caller reaches the venue.""" class MarketMaster: @@ -205,5 +205,5 @@ def __len__(self) -> int: return len(self._specs) +# The old name, kept so an import written against it still resolves. InstrumentMaster = MarketMaster -"""The old name, kept so an import written against it still resolves.""" diff --git a/synpath/trading/kalshi.py b/synpath/trading/kalshi.py index b574b4b..3102267 100644 --- a/synpath/trading/kalshi.py +++ b/synpath/trading/kalshi.py @@ -61,21 +61,24 @@ "prod": "https://external-api.kalshi.com/trade-api/v2", } +# `gtd` is `good_till_canceled` plus `expiration_time`. `day` is not here on +# purpose: the engine rewrites it to `gtd` at the session end before an +# adapter sees it, and an adapter that received one would have to invent a +# session. TIF_TO_VENUE = { TimeInForce.GTC: "good_till_canceled", TimeInForce.IOC: "immediate_or_cancel", TimeInForce.FOK: "fill_or_kill", TimeInForce.GTD: "good_till_canceled", } -"""`gtd` is `good_till_canceled` plus `expiration_time`. `day` is not here on -purpose: the engine rewrites it to `gtd` at the session end before an -adapter sees it, and an adapter that received one would have to invent a -session.""" +# Tokens a call costs unless the venue's endpoint-cost table says +# otherwise; one order is one default call, a batch is one per order. DEFAULT_COST = 10 -"""Tokens a call costs unless the venue's endpoint-cost table says -otherwise; one order is one default call, a batch is one per order.""" +# The venue's published non-default costs as of 2026-09-17, used until +# `fetch_limits` replaces them with the live table. Paths are the venue's +# own patterns: `:name` matches one segment, `*name` the rest. KNOWN_COSTS: dict[tuple[str, str], int] = { ("DELETE", "/trade-api/v2/portfolio/events/orders"): 2, ("DELETE", "/trade-api/v2/portfolio/events/orders/:order_id"): 2, @@ -86,16 +89,13 @@ ("GET", "/trade-api/v2/communications/rfqs/:rfq_id/quotes/:quote_id"): 2, ("PUT", "/trade-api/v2/communications/rfqs/:rfq_id/quotes/:quote_id/confirm"): 1, } -"""The venue's published non-default costs as of 2026-09-17, used until -`fetch_limits` replaces them with the live table. Paths are the venue's -own patterns: `:name` matches one segment, `*name` the rest.""" +# What an order is checked against when the caller gives no instrument +# spec: the coarse tick every market accepts, and the venue's two-decimal +# contract count. A market with a finer ladder wants its own `Precision`. DEFAULT_PRECISION = Precision( tick=Decimal("0.01"), min_quantity=Decimal("0.01"), quantity_step=Decimal("0.01"), whole_contracts=False, ) -"""What an order is checked against when the caller gives no instrument -spec: the coarse tick every market accepts, and the venue's two-decimal -contract count. A market with a finer ladder wants its own `Precision`.""" # --------------------------------------------------------------------------- @@ -477,15 +477,15 @@ def map_error(exc: ExchangeError, *, path: str = "") -> ExchangeError: # ordinary binary market, so its Synpath id is `kalshi:`. Its legs # are the market's `mve_selected_legs`. +# Paths whose 404 names an RFQ, a quote or a combo collection. RFQ_PATHS = ("/communications", "/multivariate_event_collections") -"""Paths whose 404 names an RFQ, a quote or a combo collection.""" +# Every multivariate market's ticker starts so; the fallback when an RFQ +# carries no `mve_collection_ticker`. COMBO_PREFIX = "KXMVE" -"""Every multivariate market's ticker starts so; the fallback when an RFQ -carries no `mve_collection_ticker`.""" +# `POST /multivariate_event_collections/{t}`: creations per user per week. WEEKLY_COMBO_QUOTA = 5000 -"""`POST /multivariate_event_collections/{t}`: creations per user per week.""" QUOTE_STATUS_FROM_WIRE = { "open": QuoteStatus.OPEN, @@ -502,18 +502,18 @@ def map_error(exc: ExchangeError, *, path: str = "") -> ExchangeError: QuoteStatus.EXECUTED: "executed", QuoteStatus.CANCELED: "cancelled", } +# The requester's YES-leg side -> the maker's bid it takes (P0, settled). ACCEPTED_SIDE_TO_WIRE = {Side.BUY: "no", Side.SELL: "yes"} -"""The requester's YES-leg side -> the maker's bid it takes (P0, settled).""" ACCEPTED_SIDE_FROM_WIRE = {wire: side for side, wire in ACCEPTED_SIDE_TO_WIRE.items()} +# A combo market's lifecycle in `Combo.status`; every other state +# (initialized, inactive, closed, disputed) reads `closed`. MARKET_STATUS_TO_COMBO = { "active": "open", "determined": "settled", "finalized": "settled", "amended": "settled", } -"""A combo market's lifecycle in `Combo.status`; every other state -(initialized, inactive, closed, disputed) reads `closed`.""" def _dollars(value: Any) -> Decimal | None: diff --git a/synpath/trading/limiter.py b/synpath/trading/limiter.py index 72ae212..60d4553 100644 --- a/synpath/trading/limiter.py +++ b/synpath/trading/limiter.py @@ -42,8 +42,8 @@ class Priority(IntEnum): + # Cancels, kill switch, the replace half of an edit. HIGH = 0 - """Cancels, kill switch, the replace half of an edit.""" NORMAL = 1 @@ -61,12 +61,12 @@ def refill(self, now: float) -> None: @dataclass class _Lane: + # The venue's budget, as reserved by every caller. main: _Bucket - """The venue's budget, as reserved by every caller.""" + # What a high-priority call may borrow ahead of the queue. fast: _Bucket - """What a high-priority call may borrow ahead of the queue.""" + # Seconds of delay injected ahead of sleeping normal callers. shift: float = 0.0 - """Seconds of delay injected ahead of sleeping normal callers.""" @dataclass(frozen=True) diff --git a/synpath/trading/limitless.py b/synpath/trading/limitless.py index 6cb83ce..c51b406 100644 --- a/synpath/trading/limitless.py +++ b/synpath/trading/limitless.py @@ -74,12 +74,12 @@ API_URL = "https://api.limitless.exchange" RPC_URL = "https://mainnet.base.org" +# An order row's status. `UNMATCHED` is an immediate order that matched nothing. ROW_STATUS = {"LIVE": OrderStatus.OPEN, "MATCHED": OrderStatus.CLOSED, "CANCELED": OrderStatus.CANCELED, "UNMATCHED": OrderStatus.CANCELED} -"""An order row's status. `UNMATCHED` is an immediate order that matched nothing.""" +# Order ids per batch cancel or status lookup: the venue's maximum. BATCH = 50 -"""Order ids per batch cancel or status lookup: the venue's maximum.""" FILL_SETTLEMENT = { "MATCHED": SettlementState.MATCHED, @@ -87,12 +87,12 @@ "FAILED": SettlementState.FAILED, } +# An immediate order's terminal result: a partial fill's rest is cancelled. EXECUTION_STATUS = { "FILLED": OrderStatus.CLOSED, "PARTIALLY_FILLED": OrderStatus.CANCELED, "KILLED": OrderStatus.CANCELED, } -"""An immediate order's terminal result: a partial fill's rest is cancelled.""" def D(value: Any) -> Decimal: @@ -197,8 +197,8 @@ class MarketInfo: yes_token: str no_token: str exchange: str + # Whether the market charges fees; a free market's orders carry a zero rate. fee: bool - """Whether the market charges fees; a free market's orders carry a zero rate.""" @property def market_id(self) -> str: @@ -489,8 +489,8 @@ def __init__( from ..limitless import Limitless catalog = Limitless() + # The read adapter: tokens, exchange and fee flag for a market. self.catalog = catalog - """The read adapter: tokens, exchange and fee flag for a market.""" self._markets: dict[str, MarketInfo] = {} self._profile: dict[str, Any] | None = None diff --git a/synpath/trading/limitless_signing.py b/synpath/trading/limitless_signing.py index 9a76ee0..694672f 100644 --- a/synpath/trading/limitless_signing.py +++ b/synpath/trading/limitless_signing.py @@ -39,8 +39,8 @@ EOA = 0 MIN_PRICE, MAX_PRICE = Decimal("0.01"), Decimal("0.99") +# The venue's dust floor on `makerAmount`, in raw units. MIN_MAKER_UNITS = 100 -"""The venue's dust floor on `makerAmount`, in raw units.""" def contracts_units(contracts: Decimal) -> int: diff --git a/synpath/trading/money.py b/synpath/trading/money.py index 47ed438..c47f18e 100644 --- a/synpath/trading/money.py +++ b/synpath/trading/money.py @@ -116,9 +116,9 @@ def validate_quantity(quantity: Decimal, precision: Precision) -> Decimal: # -- sizing by amount ------------------------------------------------------------ +# The grid a venue with no quantity step is sized on: six decimals, the +# finest any venue in VENUE_RULES uses. FALLBACK_QUANTITY_STEP = Decimal("0.000001") -"""The grid a venue with no quantity step is sized on: six decimals, the -finest any venue in VENUE_RULES uses.""" def outcome_price(yes_price: Decimal, outcome: str, *, face_value: Decimal = ONE) -> Decimal: diff --git a/synpath/trading/opinion.py b/synpath/trading/opinion.py index ecef57a..ea08685 100644 --- a/synpath/trading/opinion.py +++ b/synpath/trading/opinion.py @@ -62,18 +62,19 @@ ) VENUE = "opinion" +# The host the venue's SDK sends orders to. TRADING_URL = "https://proxy.opinion.trade:8443/openapi" -"""The host the venue's SDK sends orders to.""" +# Rows per order, trade or position page: the venue's maximum. PAGE = 20 -"""Rows per order, trade or position page: the venue's maximum.""" +# Most pages one listing call walks: 1000 rows. MAX_PAGES = 50 -"""Most pages one listing call walks: 1000 rows.""" +# The venue's documented minimum order value. MIN_ORDER_USDT = Decimal("5") -"""The venue's documented minimum order value.""" +# The venue's numeric order status. ORDER_STATUS = { 1: OrderStatus.OPEN, # pending: resting, or matched and awaiting the chain 2: OrderStatus.CLOSED, # finished @@ -81,25 +82,24 @@ 4: OrderStatus.EXPIRED, 5: OrderStatus.REJECTED, # failed } -"""The venue's numeric order status.""" +# Order statuses as the venue's filter codes. STATUS_CODES = {"open": 1, "closed": 2, "canceled": 3, "expired": 4, "rejected": 5} -"""Order statuses as the venue's filter codes.""" +# The venue's numeric trade status, as a settlement state. TRADE_SETTLEMENT = { 2: SettlementState.CONFIRMED, # finished 3: SettlementState.FAILED, # canceled 5: SettlementState.FAILED, # failed 6: SettlementState.FAILED, # failed on chain } -"""The venue's numeric trade status, as a settlement state.""" +# Trade record sides that are fills. `Split` and `Merge` are conversions +# between collateral and a YES/NO pair, not trades. FILL_SIDES = ("buy", "sell") -"""Trade record sides that are fills. `Split` and `Merge` are conversions -between collateral and a YES/NO pair, not trades.""" +# A market's status once its outcome is final. RESOLVED = 4 -"""A market's status once its outcome is final.""" def D(value: Any) -> Decimal: @@ -248,8 +248,8 @@ class MarketInfo: yes_token: str no_token: str quote_token: str + # The CTF exchange for the market's collateral: the verifying contract. exchange: str - """The CTF exchange for the market's collateral: the verifying contract.""" @property def market_id(self) -> str: @@ -413,13 +413,13 @@ def __init__( from ..opinion import Opinion catalog = Opinion(api_key=credentials.api_key) + # The read adapter: tokens, collateral and fees for a market. self.catalog = catalog - """The read adapter: tokens, collateral and fees for a market.""" + # The account's Safe, which makes every order and holds the funds. self.wallet: str | None = credentials.multisig_address - """The account's Safe, which makes every order and holds the funds.""" self._markets: dict[str, MarketInfo] = {} + # Collateral token address (lowercase) -> its CTF exchange. self._exchanges: dict[str, str] = {} - """Collateral token address (lowercase) -> its CTF exchange.""" # -- plumbing ------------------------------------------------------------- diff --git a/synpath/trading/opinion_signing.py b/synpath/trading/opinion_signing.py index a9cb7f6..9c0b427 100644 --- a/synpath/trading/opinion_signing.py +++ b/synpath/trading/opinion_signing.py @@ -39,8 +39,8 @@ MIN_PRICE = Decimal("0.001") MAX_PRICE = Decimal("0.999") +# The venue's bounds on a limit price, as its client enforces them. MAX_PRICE_PLACES = 6 -"""The venue's bounds on a limit price, as its client enforces them.""" ORDER_FIELDS = [ {"name": "salt", "type": "uint256"}, diff --git a/synpath/trading/polymarket.py b/synpath/trading/polymarket.py index 9f39327..cd6a5c7 100644 --- a/synpath/trading/polymarket.py +++ b/synpath/trading/polymarket.py @@ -76,33 +76,33 @@ GAMMA_URL = "https://gamma-api.polymarket.com" RELAYER_URL = "https://relayer-v2.polymarket.com" +# Synpath's builder signer. Polymarket's relayer accepts a builder's +# credentials in place of the account's own Relayer API key; the builder secret +# stays on Synpath's server, which answers with the headers for one request. +# It moves no money -- the relayer only runs a batch the account's own key +# signed. `SYNPATH_BUILDER_SIGNER_URL` points elsewhere, `none` turns it off. BUILDER_SIGNER_URL = "https://api.synpath.dev/v1/builder/sign" -"""Synpath's builder signer. Polymarket's relayer accepts a builder's -credentials in place of the account's own Relayer API key; the builder secret -stays on Synpath's server, which answers with the headers for one request. -It moves no money -- the relayer only runs a batch the account's own key -signed. `SYNPATH_BUILDER_SIGNER_URL` points elsewhere, `none` turns it off.""" INITIAL_CURSOR = "MA==" END_CURSOR = "LTE=" +# Orders per `POST /orders`, the venue's limit. ORDER_BATCH = 15 -"""Orders per `POST /orders`, the venue's limit.""" +# Ids per `DELETE /orders`, the venue's limit since 2026-06-15. CANCEL_BATCH = 1000 -"""Ids per `DELETE /orders`, the venue's limit since 2026-06-15.""" +# The venue expires a GTD order one minute before its stated expiration. GTD_SAFETY_S = 60 -"""The venue expires a GTD order one minute before its stated expiration.""" +# And refuses an expiration less than three minutes away. GTD_MIN_LEAD_S = 180 -"""And refuses an expiration less than three minutes away.""" +# Shares carry two decimals at every tick size. SIZE_STEP = Decimal("0.01") -"""Shares carry two decimals at every tick size.""" +# How long a token's tick, minimum and neg-risk flag are trusted. Ticks +# change as a price nears the ends; a rejection for a stale tick also clears +# the entry. MARKET_TTL_S = 60.0 -"""How long a token's tick, minimum and neg-risk flag are trusted. Ticks -change as a price nears the ends; a rejection for a stale tick also clears -the entry.""" # --------------------------------------------------------------------------- @@ -639,8 +639,8 @@ def __init__( self.data = AsyncHttpClient(data_url, limiter=None, client=shared, venue=VENUE) self.relayer = AsyncHttpClient(relayer_url, limiter=None, client=shared, venue=VENUE) self.gamma = AsyncHttpClient(gamma_url, limiter=None, client=shared, venue=VENUE) + # By Gamma id, condition id and either token id: which token is which side. self._markets: dict[str, MarketTokens] = {} - """By Gamma id, condition id and either token id: which token is which side.""" self._http = shared self._api_key = credentials.api_key self._api_secret = credentials.api_secret diff --git a/synpath/trading/polymarket_rfq.py b/synpath/trading/polymarket_rfq.py index f559aa2..e191fd0 100644 --- a/synpath/trading/polymarket_rfq.py +++ b/synpath/trading/polymarket_rfq.py @@ -93,17 +93,17 @@ REQUESTER_URL = "https://combos-rfq-gateway-requester-api.polymarket.com" REQUESTS_PATH = "/v1/requester/rfq/requests" +# The public combo catalogue and the quoter's REST commands. RFQ_API_URL = "https://combos-rfq-api.polymarket.com" -"""The public combo catalogue and the quoter's REST commands.""" QUOTER_WS_URL = "wss://combos-rfq-gateway-quoter.polymarket.com/ws/rfq" MIN_LEGS, MAX_LEGS = 2, 50 E6 = Decimal(1_000_000) +# The quoter's BUY-request safety maximum (`QUOTED_PRICE_ABOVE_SAFETY_THRESHOLD`). BUY_PRICE_CEILING = Decimal("0.90909") -"""The quoter's BUY-request safety maximum (`QUOTED_PRICE_ABOVE_SAFETY_THRESHOLD`).""" +# A quote of ours not selected this long after it was sent is read as +# expired: the competition (400 ms) and the requester's window (5-10 s) are over. MAKER_QUOTE_STALE_S = 30.0 -"""A quote of ours not selected this long after it was sent is read as -expired: the competition (400 ms) and the requester's window (5-10 s) are over.""" COMBO_PREFIX = "combo-" PROVISIONAL_PREFIX = "combo-legs-" @@ -113,6 +113,8 @@ DONE = frozenset({"FILLED", "CONFIRMED"}) NO_TRADE = frozenset({"FAILED", "EXPIRED", "CANCELED", "REJECTED"}) +# The gateway's RFQ status as the status of its one quote. FAILED with +# `MAKER_DECLINED` reads as `canceled` (declined in the last look). QUOTE_STATUS = { "CREATED": QuoteStatus.OPEN, "COLLECTING_QUOTES": QuoteStatus.OPEN, @@ -131,8 +133,6 @@ "EXPIRED": QuoteStatus.EXPIRED, "CANCELED": QuoteStatus.CANCELED, } -"""The gateway's RFQ status as the status of its one quote. FAILED with -`MAKER_DECLINED` reads as `canceled` (declined in the last look).""" CLOSE_REASON = { "FILLED": RfqCloseReason.EXECUTED, @@ -304,20 +304,20 @@ class RfqRecord: """One RFQ this adapter created, as the gateway described it.""" rfq_id: str + # The create body as sent. request: dict[str, Any] - """The create body as sent.""" + # The create answer: status, expiry, the echoed request, the quote. response: dict[str, Any] - """The create answer: status, expiry, the echoed request, the quote.""" status: str created_at: int combo: Combo | None = None + # The signed accept, kept so a retry sends the same order. accept_body: dict[str, Any] | None = None - """The signed accept, kept so a retry sends the same order.""" accept_side: Side | None = None accepted_at: int | None = None order_hash: str | None = None + # The latest status answer after the accept. last: dict[str, Any] = field(default_factory=dict) - """The latest status answer after the accept.""" canceled: bool = False @property @@ -360,9 +360,9 @@ class MakerRecord: rfq_id: str request: dict[str, Any] sent_at: int + # OPEN, ACCEPTED (a last-look request), CONFIRMED (handed to execution), + # EXECUTED, FAILED, CANCELED, EXPIRED. status: str = "OPEN" - """OPEN, ACCEPTED (a last-look request), CONFIRMED (handed to execution), - EXECUTED, FAILED, CANCELED, EXPIRED.""" fill_size_e6: int | None = None tx_hash: str | None = None detail: dict[str, Any] = field(default_factory=dict) @@ -486,13 +486,13 @@ async def handle(self, message: dict[str, Any]) -> None: class PolymarketRfqMixin: """Combo RFQs on `PolymarketTrading`. See the module doc.""" + # The requester gateway has no cancel: `cancel_rfq` only drops the RFQ + # here and it lapses at `expires_at`. Read by the engine's cross-venue + # groups (`synpath.engine.rfq_group`), which record such a venue as left + # to expire rather than cancelled. rfq_requester_cancel = False - """The requester gateway has no cancel: `cancel_rfq` only drops the RFQ - here and it lapses at `expires_at`. Read by the engine's cross-venue - groups (`synpath.engine.rfq_group`), which record such a venue as left - to expire rather than cancelled.""" + # An RFQ asks one side (YES, `buy` by amount or `sell` by quantity). rfq_one_sided = True - """An RFQ asks one side (YES, `buy` by amount or `sell` by quantity).""" # Set by `PolymarketTrading.__init__` through `_init_rfq`. requester: AsyncHttpClient @@ -503,10 +503,10 @@ def _init_rfq(self, shared: Any, *, requester_url: str = REQUESTER_URL, rfq_api_ self.rfq_api = AsyncHttpClient(rfq_api_url, limiter=None, client=shared, venue=VENUE) self._rfq_records: dict[str, RfqRecord] = {} self._quote_to_rfq: dict[str, str] = {} + # By combo id (real and provisional) and by its sorted leg set. self._combos: dict[str, Combo] = {} - """By combo id (real and provisional) and by its sorted leg set.""" + # Leg position id -> (Synpath market id, outcome). self._positions: dict[str, tuple[str, str]] = {} - """Leg position id -> (Synpath market id, outcome).""" self._position_of: dict[tuple[str, str], str] = {} self._incoming: dict[str, tuple[Rfq, dict[str, Any]]] = {} self._maker: dict[str, MakerRecord] = {} diff --git a/synpath/trading/polymarket_signing.py b/synpath/trading/polymarket_signing.py index cd651b0..078ac0f 100644 --- a/synpath/trading/polymarket_signing.py +++ b/synpath/trading/polymarket_signing.py @@ -41,13 +41,13 @@ PUSD = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" CTF_COLLATERAL_ADAPTER = "0xAdA100Db00Ca00073811820692005400218FcE1f" NEG_RISK_CTF_COLLATERAL_ADAPTER = "0xadA2005600Dec949baf300f4C6120000bDB6eAab" +# The CLOB checks the wallet's pUSD allowance to this adapter before it accepts +# an order on a neg-risk market (several outcomes of one event), as well as the +# neg-risk exchange's (seen 2026-10-13: an order refused with `spender: +# 0xd91E…, allowance: 0`). NEG_RISK_ADAPTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" -"""The CLOB checks the wallet's pUSD allowance to this adapter before it accepts -an order on a neg-risk market (several outcomes of one event), as well as the -neg-risk exchange's (seen 2026-10-13: an order refused with `spender: -0xd91E…, allowance: 0`).""" +# Addresses from the venue's contract page, Polygon mainnet, CLOB V2. DEPOSIT_WALLET_FACTORY = "0x00000000000Fb5C9ADea0298D729A0CB3823Cc07" -"""Addresses from the venue's contract page, Polygon mainnet, CLOB V2.""" ZERO_BYTES32 = "0x" + "00" * 32 MAX_UINT256 = 2**256 - 1 @@ -84,8 +84,8 @@ EOA, POLY_PROXY, POLY_GNOSIS_SAFE, DEPOSIT_WALLET = 0, 1, 2, 3 +# pUSD and outcome tokens both carry six decimals. SCALE = Decimal(10) ** 6 -"""pUSD and outcome tokens both carry six decimals.""" def _eth(): @@ -214,6 +214,8 @@ class RoundConfig: amount: int +# The venue's precision table: decimals for price, share size and the +# USD amount at each tick size. ROUNDING: dict[str, RoundConfig] = { "0.1": RoundConfig(price=1, size=2, amount=3), "0.01": RoundConfig(price=2, size=2, amount=4), @@ -222,8 +224,6 @@ class RoundConfig: "0.001": RoundConfig(price=3, size=2, amount=5), "0.0001": RoundConfig(price=4, size=2, amount=6), } -"""The venue's precision table: decimals for price, share size and the -USD amount at each tick size.""" def round_config(tick: Decimal) -> RoundConfig: diff --git a/synpath/trading/polymarket_us.py b/synpath/trading/polymarket_us.py index a7c1e0d..48c8e04 100644 --- a/synpath/trading/polymarket_us.py +++ b/synpath/trading/polymarket_us.py @@ -61,28 +61,28 @@ BATCH = 20 PRICE_FLOOR = Decimal("0.01") +# The exchange's absolute price limits. A price outside them still gets an +# order id and is then rejected, so it is refused here first. PRICE_CEILING = Decimal("0.99") -"""The exchange's absolute price limits. A price outside them still gets an -order id and is then rejected, so it is refused here first.""" MARKET_TTL_S = 300.0 +# The venue's per-endpoint limit on its RFQ list and create calls. RFQ_PACE_PER_SECOND = 1.0 -"""The venue's per-endpoint limit on its RFQ list and create calls.""" +# Endpoints the venue limits to one request a second, each to its pace +# group (docs/rfq-combo.md, Polymarket US): `GET /v1/rfqs`, `GET +# /v1/rfqs/quotes`, and `POST /v1/rfqs` with `POST /v1/combos`, which share +# one budget. Exact paths: `/v1/rfqs/user-id` and the per-id writes (cancel, +# accept, confirm) only meet the shared limiter. PACED_ENDPOINTS: dict[tuple[str, str], str] = { ("GET", "/v1/rfqs"): "rfqs", ("GET", "/v1/rfqs/quotes"): "rfq_quotes", ("POST", "/v1/rfqs"): "rfq_create", ("POST", "/v1/combos"): "rfq_create", } -"""Endpoints the venue limits to one request a second, each to its pace -group (docs/rfq-combo.md, Polymarket US): `GET /v1/rfqs`, `GET -/v1/rfqs/quotes`, and `POST /v1/rfqs` with `POST /v1/combos`, which share -one budget. Exact paths: `/v1/rfqs/user-id` and the per-id writes (cancel, -accept, confirm) only meet the shared limiter.""" +# How long a paced call may queue behind others in its group before it is +# refused (`RateBudgetExceeded`) rather than sent late. PACE_MAX_WAIT_S = 15.0 -"""How long a paced call may queue behind others in its group before it is -refused (`RateBudgetExceeded`) rather than sent late.""" # --------------------------------------------------------------------------- @@ -215,6 +215,8 @@ def translate_order(request: OrderRequest, rules: MarketRules) -> dict[str, Any] # Normalizers # --------------------------------------------------------------------------- +# `REPLACED` is the superseded half of a cancel-replace; it will not trade +# again, so it reads as cancelled with the native state kept in `info`. STATE = { "ORDER_STATE_PENDING_NEW": OrderStatus.PENDING, "ORDER_STATE_PENDING_RISK": OrderStatus.PENDING, @@ -228,8 +230,6 @@ def translate_order(request: OrderRequest, rules: MarketRules) -> dict[str, Any] "ORDER_STATE_REJECTED": OrderStatus.REJECTED, "ORDER_STATE_EXPIRED": OrderStatus.EXPIRED, } -"""`REPLACED` is the superseded half of a cancel-replace; it will not trade -again, so it reads as cancelled with the native state kept in `info`.""" INTENT = { "ORDER_INTENT_BUY_LONG": ("yes", Side.BUY), @@ -418,9 +418,9 @@ def error_of(exc: ExchangeError) -> ExchangeError: # A price of "0" marks a side the maker declined. MAX_COMBO_LEGS = 50 +# The documented combo symbol prefixes. The venue says to treat symbols as +# opaque; this is only the fallback when an RFQ carries no `comboLegs`. COMBO_PREFIXES = ("caoc-", "crossd-") -"""The documented combo symbol prefixes. The venue says to treat symbols as -opaque; this is only the fallback when an RFQ carries no `comboLegs`.""" SIDE_TO_WIRE = {Side.BUY: "SIDE_BUY", Side.SELL: "SIDE_SELL"} SIDE_FROM_WIRE = {wire: side for side, wire in SIDE_TO_WIRE.items()} @@ -439,10 +439,10 @@ def error_of(exc: ExchangeError) -> ExchangeError: } QUOTE_STATUS_TO_WIRE = {status: wire for wire, status in QUOTE_STATUS_FROM_WIRE.items()} +# Every other instrument state (pre-open, suspended, halted, closed, +# expired, terminated...) reads as `closed`; the native state stays in `info`. +# Settlement is read on the symbol, not from this state. COMBO_STATE = {"INSTRUMENT_STATE_OPEN": "open"} -"""Every other instrument state (pre-open, suspended, halted, closed, -expired, terminated...) reads as `closed`; the native state stays in `info`. -Settlement is read on the symbol, not from this state.""" def decimal_text(value: Decimal) -> str: @@ -493,8 +493,8 @@ def combo_of(raw: dict[str, Any]) -> Combo: ) +# Events read per page of `fetch_combo_eligible_markets` when no limit is given. COMBO_EVENTS_PAGE = 50 -"""Events read per page of `fetch_combo_eligible_markets` when no limit is given.""" def combo_markets_of(event: dict[str, Any]) -> list[ComboMarket]: diff --git a/synpath/trading/polymarket_us_exchange.py b/synpath/trading/polymarket_us_exchange.py index 4a4aaf3..9a2f7a7 100644 --- a/synpath/trading/polymarket_us_exchange.py +++ b/synpath/trading/polymarket_us_exchange.py @@ -68,8 +68,8 @@ GATEWAY_URL = "https://gateway.polymarket.us" BATCH = 20 +# Refresh the access token this long before the venue says it expires. TOKEN_MARGIN_S = 30 -"""Refresh the access token this long before the venue says it expires.""" NATIVE_ORDER_TYPES = frozenset({OrderType.LIMIT, OrderType.MARKET, OrderType.STOP_MARKET, OrderType.STOP_LIMIT}) @@ -442,8 +442,8 @@ class PolymarketUSExchangeTrading(TradingExchange): id = VENUE name = "Polymarket US (exchange API)" + # Order types the exchange holds itself, stops included. native_order_types = NATIVE_ORDER_TYPES - """Order types the exchange holds itself, stops included.""" has: dict[str, Capability] = { "create_order": True, "create_orders": True, diff --git a/synpath/trading/polymarket_v3_signing.py b/synpath/trading/polymarket_v3_signing.py index 4d5ba8c..db4a073 100644 --- a/synpath/trading/polymarket_v3_signing.py +++ b/synpath/trading/polymarket_v3_signing.py @@ -44,12 +44,12 @@ CHAIN_ID = v2.CHAIN_ID EXCHANGE_V3 = "0xe3333700cA9d93003F00f0F71f8515005F6c00Aa" +# Owns the ERC-1155 combo positions (Polymarket Protocol V2). POSITION_MANAGER = "0x006F54F7f9A22e0000CC2AB60031000000ae9fEF" -"""Owns the ERC-1155 combo positions (Polymarket Protocol V2).""" ROUTER = "0x12121212006e4CD160D18e3f00711DA5c3372600" AUTO_REDEEMER = "0xa1200000d0002264C9a1698e001292D00E1b00af" +# Addresses from the venue's contracts page and the market-makers guide, Polygon mainnet. PUSD = v2.PUSD -"""Addresses from the venue's contracts page and the market-makers guide, Polygon mainnet.""" DOMAIN_NAME = v2.EXCHANGE_DOMAIN_NAME DOMAIN_VERSION = "3" @@ -75,8 +75,8 @@ ZERO_BYTES32 = v2.ZERO_BYTES32 EOA, POLY_PROXY, POLY_GNOSIS_SAFE, DEPOSIT_WALLET = v2.EOA, v2.POLY_PROXY, v2.POLY_GNOSIS_SAFE, v2.DEPOSIT_WALLET SIGNATURE_TYPES = (EOA, POLY_PROXY, POLY_GNOSIS_SAFE, DEPOSIT_WALLET) +# Both requester gateways refuse an EOA. REQUESTER_SIGNATURE_TYPES = (POLY_PROXY, POLY_GNOSIS_SAFE, DEPOSIT_WALLET) -"""Both requester gateways refuse an EOA.""" E6 = 1_000_000 BUY, SELL = 0, 1 diff --git a/synpath/trading/predict_fun.py b/synpath/trading/predict_fun.py index 36c534d..0b88285 100644 --- a/synpath/trading/predict_fun.py +++ b/synpath/trading/predict_fun.py @@ -71,18 +71,20 @@ RPC_URL = "https://bsc-dataseed.bnbchain.org" TESTNET_RPC_URL = "https://data-seed-prebsc-1-s1.bnbchain.org:8545" +# Rows per order, trade or position page. PAGE = 100 -"""Rows per order, trade or position page.""" +# Most pages one listing call walks. MAX_PAGES = 20 -"""Most pages one listing call walks.""" +# How long a market order's signature lives, as the SDK signs it. MARKET_EXPIRY_S = 300 -"""How long a market order's signature lives, as the SDK signs it.""" +# A login token this close to expiry is renewed before use. JWT_MARGIN_S = 60 -"""A login token this close to expiry is renewed before use.""" +# An order row's status. `INVALIDATED` is an order the chain no longer +# honours (cancelled there, or its funds gone). ROW_STATUS = { "OPEN": OrderStatus.OPEN, "FILLED": OrderStatus.CLOSED, @@ -90,20 +92,18 @@ "CANCELLED": OrderStatus.CANCELED, "INVALIDATED": OrderStatus.CANCELED, } -"""An order row's status. `INVALIDATED` is an order the chain no longer -honours (cancelled there, or its funds gone).""" +# The statuses the venue's order listing filters by. LIST_STATUS = {"open": "OPEN", "closed": "FILLED"} -"""The statuses the venue's order listing filters by.""" +# Order events whose status the event itself says. A transaction event's +# status follows from how much is filled. ORDER_EVENT_STATUS = { "orderAccepted": OrderStatus.OPEN, "orderNotAccepted": OrderStatus.REJECTED, "orderExpired": OrderStatus.EXPIRED, "orderCancelled": OrderStatus.CANCELED, } -"""Order events whose status the event itself says. A transaction event's -status follows from how much is filled.""" FILL_SETTLEMENT = { "orderTransactionSubmitted": SettlementState.MATCHED, @@ -508,10 +508,10 @@ def __init__( self.signer = WalletSigner(credentials.private_key) self.account = Account(venue=VENUE, name=account_name) self.network = sig.TESTNET if credentials.testnet else sig.MAINNET + # The Predict account the key signs for, or `None` for a plain wallet. self.predict_account = credentials.account_address - """The Predict account the key signs for, or `None` for a plain wallet.""" + # Maker of every order, and holder of the funds. self.address = credentials.account_address or self.signer.address - """Maker of every order, and holder of the funds.""" # 240 requests a minute per API key, every kind together. self.limiter = limiter or BudgetLimiter(read_per_second=2, write_per_second=2) headers = {"x-api-key": credentials.api_key} if credentials.api_key else {} @@ -519,15 +519,15 @@ def __init__( base = url or (TESTNET_API_URL if credentials.testnet else API_URL) self.http = AsyncHttpClient(base, limiter=None, client=shared, venue=VENUE) self.rpc_url = rpc_url or credentials.rpc_url or (TESTNET_RPC_URL if credentials.testnet else RPC_URL) + # A BNB Chain node, for the balance read. self.rpc = rpc_client or httpx.AsyncClient(timeout=20, follow_redirects=True) - """A BNB Chain node, for the balance read.""" self._own_catalog = catalog is None if catalog is None: from ..predict_fun import PredictFun catalog = PredictFun(api_key=credentials.api_key, testnet=credentials.testnet) + # The read adapter: tokens, exchange and fee rate for a market. self.catalog = catalog - """The read adapter: tokens, exchange and fee rate for a market.""" self._markets: dict[str, MarketInfo] = {} self._jwt: str | None = None self._jwt_expires: int | None = None diff --git a/synpath/trading/predict_fun_signing.py b/synpath/trading/predict_fun_signing.py index 1aa377e..63a593b 100644 --- a/synpath/trading/predict_fun_signing.py +++ b/synpath/trading/predict_fun_signing.py @@ -39,16 +39,16 @@ ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" SCALE = 10**18 BUY, SELL = 0, 1 +# The only signature type the venue takes; a Predict account signs under it +# too, through EIP-1271. EOA = 0 -"""The only signature type the venue takes; a Predict account signs under it -too, through EIP-1271.""" MAX_SALT = 2_147_483_648 +# 2100-01-01, the SDK's expiration for an order that rests until cancelled. NO_EXPIRY = 4_102_444_800 -"""2100-01-01, the SDK's expiration for an order that rests until cancelled.""" +# The SDK's smallest order: 0.01 shares. MIN_SHARES_WEI = 10**16 -"""The SDK's smallest order: 0.01 shares.""" @dataclass(frozen=True) diff --git a/synpath/trading/sx_bet.py b/synpath/trading/sx_bet.py index e7ba42b..ece9774 100644 --- a/synpath/trading/sx_bet.py +++ b/synpath/trading/sx_bet.py @@ -81,13 +81,14 @@ USDC = Decimal(1_000_000) ODDS = Decimal(10) ** 20 +# How long a GTC order is signed to live: the venue needs an expiry, and a +# game's orders are swept at kickoff long before this. DEFAULT_EXPIRY_S = 30 * 86400 -"""How long a GTC order is signed to live: the venue needs an expiry, and a -game's orders are swept at kickoff long before this.""" +# How long `create_order` lets the venue wait for the matching result. WAIT_MS = 10_000 -"""How long `create_order` lets the venue wait for the matching result.""" +# What the venue's synchronous answer says became of an order on arrival. OUTCOME_STATUS = { "RESTED": OrderStatus.OPEN, "PARTIAL_FILL_RESTED": OrderStatus.OPEN, @@ -96,14 +97,13 @@ "CANCELLED": OrderStatus.CANCELED, "TIMEOUT": OrderStatus.PENDING, } -"""What the venue's synchronous answer says became of an order on arrival.""" +# An inactive order's state by its `inactiveReason`; every other reason +# (cancelled by the account, the venue, a heartbeat, kickoff) is a cancel. INACTIVE_STATUS = { "FILLED": OrderStatus.CLOSED, "EXPIRED": OrderStatus.EXPIRED, } -"""An inactive order's state by its `inactiveReason`; every other reason -(cancelled by the account, the venue, a heartbeat, kickoff) is a cancel.""" def D(value: Any) -> Decimal: @@ -243,19 +243,19 @@ def error_of(exc: ExchangeError) -> ExchangeError: # Parlays (docs/rfq-combo.md, P8) # --------------------------------------------------------------------------- +# A parlay market takes orders for 60 seconds after the bettor's request. PARLAY_LIFETIME_S = 60 -"""A parlay market takes orders for 60 seconds after the bettor's request.""" +# How much earlier than the estimated end of the parlay a quote's orders are +# signed to expire. The request carries no expiry, only the 60 s rule, and it +# reaches us after it was made; an order signed past the parlay's end is +# refused (`PARLAY_ORDER_EXPIRY_BEYOND_PARLAY_EXPIRY`, which names the exact +# end, and the quote is re-signed to it once). PARLAY_EXPIRY_MARGIN_S = 3 -"""How much earlier than the estimated end of the parlay a quote's orders are -signed to expire. The request carries no expiry, only the 60 s rule, and it -reaches us after it was made; an order signed past the parlay's end is -refused (`PARLAY_ORDER_EXPIRY_BEYOND_PARLAY_EXPIRY`, which names the exact -end, and the quote is re-signed to it once).""" +# How long a parlay request is remembered after it closes, so a quote on it +# can still be read back by its RFQ. PARLAY_KEEP_S = 600 -"""How long a parlay request is remembered after it closes, so a quote on it -can still be read back by its RFQ.""" VOID_LEG_RULE = ( "SX Bet's documents disagree on a VOID leg: the developer docs (developers/parlays) say one VOID leg voids the " @@ -587,8 +587,8 @@ def __init__( self.credentials = credentials self.testnet = bool(credentials.testnet) + # Signs new orders; `None` with the API key alone, which does everything else. self.signer = WalletSigner(credentials.private_key) if credentials.private_key else None - """Signs new orders; `None` with the API key alone, which does everything else.""" self.account = Account(venue=VENUE, name=account_name) self.limiter = limiter or BudgetLimiter(read_per_second=4, write_per_second=10) self.url = url or (TESTNET_API_URL if self.testnet else API_URL) @@ -599,19 +599,19 @@ def __init__( from ..sx_bet import SxBet catalog = SxBet(api_key=credentials.api_key, api_url=self.url) + # The read adapter: fee schedules. self.catalog = catalog - """The read adapter: fee schedules.""" self._meta: dict[str, Any] | None = None + # The `parlay_markets:global` stream parlay requests come from; an + # `SxBetParlayStream` is made on the first `watch_parlays()` if none is given. self.parlay_stream = parlay_stream - """The `parlay_markets:global` stream parlay requests come from; an - `SxBetParlayStream` is made on the first `watch_parlays()` if none is given.""" self._own_parlay_stream = parlay_stream is None self._parlay_task: asyncio.Task | None = None + # Parlay requests seen on the stream, by parlay market hash. self.parlays: dict[str, Rfq] = {} - """Parlay requests seen on the stream, by parlay market hash.""" self._rfq_listeners: list[Callable[[Rfq], Any]] = [] + # Quote id -> parlay hash, for the quotes sent through this adapter. self._quotes: dict[str, str] = {} - """Quote id -> parlay hash, for the quotes sent through this adapter.""" self.clock: Callable[[], float] = time.time @property @@ -1188,8 +1188,8 @@ async def close(self) -> None: self.catalog.close() +# Most cancel-all calls one `cancel_all_orders` makes while the venue says more are left. MAX_CANCEL_ALL_ROUNDS = 50 -"""Most cancel-all calls one `cancel_all_orders` makes while the venue says more are left.""" def _iso(ms: int) -> str: diff --git a/synpath/trading/sx_bet_signing.py b/synpath/trading/sx_bet_signing.py index 3e65b14..ea511e3 100644 --- a/synpath/trading/sx_bet_signing.py +++ b/synpath/trading/sx_bet_signing.py @@ -27,10 +27,10 @@ ODDS_SCALE = 10 ** 20 USDC = 10 ** 6 +# `oddsLadderStepSize` is counted in these: 125 is 0.125%. LADDER_UNIT = 10 ** 15 -"""`oddsLadderStepSize` is counted in these: 125 is 0.125%.""" +# 0.1% and 99.9% are always on the ladder, whatever its step. LADDER_EXCEPTIONS = frozenset({10 ** 17, 999 * 10 ** 17}) -"""0.1% and 99.9% are always on the ladder, whatever its step.""" ORDER_TYPE = ( "Order(bytes32 marketHash,address baseToken,uint256 totalBetSize,uint256 percentageOdds,uint256 salt," diff --git a/synpath/trading/types.py b/synpath/trading/types.py index 0ff1a86..ccd6da7 100644 --- a/synpath/trading/types.py +++ b/synpath/trading/types.py @@ -52,10 +52,10 @@ class Side(str, Enum): SELL = "sell" +# The outcome an order trades: which token it buys or sells. Outcome = Literal["yes", "no"] -"""The outcome an order trades: which token it buys or sells.""" +# What an order does with its outcome. Action = Literal["buy", "sell"] -"""What an order does with its outcome.""" def yes_side(outcome: str, action: str) -> Side: @@ -110,10 +110,10 @@ class OrderType(str, Enum): TWAP = "twap" PEG = "peg" SMART_TAKER = "smart_taker" + # The requester's order from an accepted RFQ quote (docs/rfq-combo.md). RFQ_TAKER = "rfq_taker" - """The requester's order from an accepted RFQ quote (docs/rfq-combo.md).""" + # The quoter's order from a quote it confirmed. RFQ_MAKER = "rfq_maker" - """The quoter's order from a quote it confirmed.""" VENUE_ORDER_TYPES = frozenset({OrderType.LIMIT, OrderType.MARKET}) @@ -124,9 +124,9 @@ class TimeInForce(str, Enum): IOC = "ioc" FOK = "fok" GTD = "gtd" + # Not a venue concept on any of the three; the engine rewrites it to + # `gtd` at the configured session end before it reaches an adapter. DAY = "day" - """Not a venue concept on any of the three; the engine rewrites it to - `gtd` at the configured session end before it reaches an adapter.""" class TriggerSource(str, Enum): @@ -145,22 +145,22 @@ class TriggerSource(str, Enum): class OrderStatus(str, Enum): + # Accepted by this library, not yet acknowledged by the venue. PENDING = "pending" - """Accepted by this library, not yet acknowledged by the venue.""" + # Resting at the venue. Read `filled` and `remaining` for how much. OPEN = "open" - """Resting at the venue. Read `filled` and `remaining` for how much.""" PENDING_CANCEL = "pending_cancel" PENDING_REPLACE = "pending_replace" + # Fully filled. CLOSED = "closed" - """Fully filled.""" + # Ended by a cancel; `filled` may be non-zero. CANCELED = "canceled" - """Ended by a cancel; `filled` may be non-zero.""" REJECTED = "rejected" EXPIRED = "expired" + # An engine-held parent whose condition has not been met. WAITING = "waiting" - """An engine-held parent whose condition has not been met.""" + # An engine-held parent whose condition fired; its children are live. TRIGGERED = "triggered" - """An engine-held parent whose condition fired; its children are live.""" TERMINAL_STATUSES = frozenset({ @@ -174,13 +174,13 @@ class HeldBy(str, Enum): class SettlementState(str, Enum): + # Matched at the venue, not yet final. Polymarket only, before the chain + # confirms. MATCHED = "matched" - """Matched at the venue, not yet final. Polymarket only, before the chain - confirms.""" + # Final. CONFIRMED = "confirmed" - """Final.""" + # Matched and then failed on-chain; the shares never arrived. FAILED = "failed" - """Matched and then failed on-chain; the shares never arrived.""" class Liquidity(str, Enum): @@ -216,8 +216,8 @@ class Account(_Base): """ venue: str + # Which credential set, for a caller with more than one at a venue. name: str = "default" - """Which credential set, for a caller with more than one at a venue.""" subaccount: str | None = None @property @@ -229,76 +229,76 @@ def key(self) -> str: class Precision(_Base): """What an order for this instrument must satisfy before it is signed.""" + # Minimum price increment. Polymarket varies it per market. tick: Decimal - """Minimum price increment. Polymarket varies it per market.""" min_quantity: Decimal = Decimal("1") + # Contract increment where the venue has one; `None` means any decimal. quantity_step: Decimal | None = None - """Contract increment where the venue has one; `None` means any decimal.""" + # Polymarket US: integers only. whole_contracts: bool = False - """Polymarket US: integers only.""" face_value: Decimal = Decimal("1") + # Smallest order the venue takes, in collateral: what the token actually + # bought costs (Opinion: 5 USDT). `None` where the venue counts only + # contracts. min_notional: Decimal | None = None - """Smallest order the venue takes, in collateral: what the token actually - bought costs (Opinion: 5 USDT). `None` where the venue counts only - contracts.""" @renamed_fields(QUANTITY) # TODO(0.9): remove class OrderRequest(_Base): """What a caller asks for. Validated against the market before signing.""" + # Application ownership, assigned by the execution worker; not a venue account. user_id: str | None = None - """Application ownership, assigned by the execution worker; not a venue account.""" + # Synpath id, `venue:native`. A bare native id is accepted by the venue's + # own adapter. market_id: str - """Synpath id, `venue:native`. A bare native id is accepted by the venue's - own adapter.""" + # `buy` takes YES, `sell` takes NO, on the YES leg. See `Side`. Set + # from `outcome` and `action` when they are given and it is not. side: Side - """`buy` takes YES, `sell` takes NO, on the YES leg. See `Side`. Set - from `outcome` and `action` when they are given and it is not.""" outcome: Outcome | None = None + # What the order does, in the caller's terms: buy or sell YES or NO. + # The engine, ledger and risk work on the YES leg (`side`, the YES + # `price`); a venue adapter reads these to send it. On a venue that nets + # (Kalshi, Polymarket US) Buy NO and Sell YES are the same order. Where YES + # and NO are separate tokens (Polymarket, Opinion, Limitless, predict.fun, + # Hyperliquid) they are not: `sell` sells the tokens held, `buy` buys them. + # Both or neither: with neither, `side` decides (`default_leg`: `buy` buys + # YES, `sell` buys NO). action: Action | None = None - """What the order does, in the caller's terms: buy or sell YES or NO. - The engine, ledger and risk work on the YES leg (`side`, the YES - `price`); a venue adapter reads these to send it. On a venue that nets - (Kalshi, Polymarket US) Buy NO and Sell YES are the same order. Where YES - and NO are separate tokens (Polymarket, Opinion, Limitless, predict.fun, - Hyperliquid) they are not: `sell` sells the tokens held, `buy` buys them. - Both or neither: with neither, `side` decides (`default_leg`: `buy` buys - YES, `sell` buys NO).""" + # Contracts. quantity: Decimal - """Contracts.""" type: OrderType = OrderType.LIMIT + # Always the YES price, 0 to 1. Required for a limit order. For a market + # order, the protection price the engine will not cross; refused without + # one on a venue without native market orders. price: Decimal | None = None - """Always the YES price, 0 to 1. Required for a limit order. For a market - order, the protection price the engine will not cross; refused without - one on a venue without native market orders.""" + # The level a stop triggers at: a buy stop when the watched price rises + # to it, a sell stop when it falls to it. stop_price: Decimal | None = None - """The level a stop triggers at: a buy stop when the watched price rises - to it, a sell stop when it falls to it.""" + # What a stop watches; see `TriggerSource`. Stops only. trigger_source: TriggerSource = TriggerSource.TOUCH - """What a stop watches; see `TriggerSource`. Stops only.""" + # For a stop, how long the whole order lives, armed and after it + # triggers, as IBKR and FIX use it: `gtc`, `gtd` or `day`. A `stop_limit`'s + # child inherits it (a `day` one as `gtd` at the same session end); a + # `stop_market`'s child is `ioc` within that life. `ioc` and `fok` are + # refused on a stop. time_in_force: TimeInForce = TimeInForce.GTC - """For a stop, how long the whole order lives, armed and after it - triggers, as IBKR and FIX use it: `gtc`, `gtd` or `day`. A `stop_limit`'s - child inherits it (a `day` one as `gtd` at the same session end); a - `stop_market`'s child is `ioc` within that life. `ioc` and `fok` are - refused on a stop.""" + # Milliseconds since epoch. Required with `gtd`. expires_at: int | None = None - """Milliseconds since epoch. Required with `gtd`.""" post_only: bool = False + # The idempotency key. Generated before the first write if absent, and + # reused on every retry of the same intent. client_order_id: str | None = None - """The idempotency key. Generated before the first write if absent, and - reused on every retry of the same intent.""" account: Account | None = None + # Strategy or desk the order belongs to. Internal, independent of the + # venue account; positions and P&L roll up by it. book: str | None = None - """Strategy or desk the order belongs to. Internal, independent of the - venue account; positions and P&L roll up by it.""" trader: str | None = None tags: dict[str, str] = Field(default_factory=dict) notes: str | None = None + # Venue-specific extras passed through untouched (Kalshi + # `self_trade_prevention_type`, Polymarket `signatureType`, ...). params: dict[str, Any] = Field(default_factory=dict) - """Venue-specific extras passed through untouched (Kalshi - `self_trade_prevention_type`, Polymarket `signatureType`, ...).""" @classmethod def stored(cls, data: Any) -> "OrderRequest": @@ -368,8 +368,8 @@ class EditRequest(_Base): quantity: Decimal | None = None time_in_force: TimeInForce | None = None expires_at: int | None = None + # Idempotency key for the *edit*, so a retried edit is not applied twice. client_order_id: str | None = None - """Idempotency key for the *edit*, so a retried edit is not applied twice.""" @renamed_fields({**QUANTITY, "last_fill_amount": "last_fill_quantity"}) # TODO(0.9): remove @@ -381,9 +381,9 @@ class Order(_Base): client_order_id: str | None = None venue: str | None account: Account | None = None + # Eligible execution accounts captured by a routed engine parent. + # Its venue and singular account are null; actual children remain venue orders. execution_accounts: dict[str, Account] = Field(default_factory=dict) - """Eligible execution accounts captured by a routed engine parent. - Its venue and singular account are null; actual children remain venue orders.""" @model_validator(mode="after") def validate_execution_identity(self): @@ -395,11 +395,11 @@ def validate_execution_identity(self): elif self.venue is None: raise ValueError("an order without a venue needs its captured execution accounts") return self + # Synpath id, `venue:native`. market_id: str - """Synpath id, `venue:native`.""" + # On the YES leg: `buy` is a YES order, `sell` a NO order, whatever the + # venue's own wording. `price` is the YES price. side: Side - """On the YES leg: `buy` is a YES order, `sell` a NO order, whatever the - venue's own wording. `price` is the YES price.""" type: OrderType time_in_force: TimeInForce status: OrderStatus @@ -408,31 +408,31 @@ def validate_execution_identity(self): stop_price: Decimal | None = None quantity: Decimal filled: Decimal = Decimal("0") + # `quantity - filled` unless the venue reports otherwise. remaining: Decimal | None = None - """`quantity - filled` unless the venue reports otherwise.""" average_price: Decimal | None = None + # Collateral committed so far. cost: Decimal | None = None - """Collateral committed so far.""" fee: Decimal | None = None fee_currency: str | None = None last_fill_price: Decimal | None = None last_fill_quantity: Decimal | None = None post_only: bool = False outcome: Outcome | None = None + # What the order does (see `OrderRequest.outcome`): as the caller gave it + # on an order the engine placed, as the venue reports it where YES and NO + # are separate tokens, else unknown (`None`). An order placed with a side + # alone has none: on a netting venue a sell may close YES or open NO. action: Action | None = None - """What the order does (see `OrderRequest.outcome`): as the caller gave it - on an order the engine placed, as the venue reports it where YES and NO - are separate tokens, else unknown (`None`). An order placed with a side - alone has none: on a netting venue a sell may close YES or open NO.""" expires_at: int | None = None created_at: int | None = None updated_at: int | None = None + # The engine-held parent this order is a child of. parent_id: str | None = None - """The engine-held parent this order is a child of.""" + # After an edit: whether the venue kept the order's place in the queue. + # Kalshi keeps it on a decrease and loses it on a price change; Polymarket + # always loses it because an edit is a cancel and a new order. queue_priority_preserved: bool | None = None - """After an edit: whether the venue kept the order's place in the queue. - Kalshi keeps it on a decrease and loses it on a price change; Polymarket - always loses it because an edit is a cancel and a new order.""" book: str | None = None trader: str | None = None tags: dict[str, str] = Field(default_factory=dict) @@ -467,20 +467,20 @@ class Fill(_Base): venue: str account: Account | None = None market_id: str + # On the YES leg, like `Order.side`. side: Side - """On the YES leg, like `Order.side`.""" outcome: Outcome | None = None + # What the fill did, its order's outcome and action (see + # `OrderRequest.outcome`), where the order is known; else unknown. action: Action | None = None - """What the fill did, its order's outcome and action (see - `OrderRequest.outcome`), where the order is known; else unknown.""" + # The YES price. price: Decimal - """The YES price.""" quantity: Decimal fee: Decimal | None = None fee_currency: str | None = None liquidity: Liquidity = Liquidity.UNKNOWN + # `matched` until the chain confirms, on Polymarket. Final elsewhere. settlement: SettlementState = SettlementState.CONFIRMED - """`matched` until the chain confirms, on Polymarket. Final elsewhere.""" timestamp: int info: dict[str, Any] = Field(default_factory=dict) @@ -496,29 +496,29 @@ class Position(_Base): venue: str account: Account | None = None market_id: str + # `long` holds YES, `short` holds NO. side: PositionSide = PositionSide.FLAT - """`long` holds YES, `short` holds NO.""" + # Net exposure on the side named, positive. On a netting venue this is + # the position; on Polymarket it is `|inventory_yes - inventory_no|`. contracts: Decimal = Decimal("0") - """Net exposure on the side named, positive. On a netting venue this is - the position; on Polymarket it is `|inventory_yes - inventory_no|`.""" inventory_yes: Decimal | None = None + # Tokens actually held on each side, on a venue that does not net + # (Polymarket). `None` elsewhere. inventory_no: Decimal | None = None - """Tokens actually held on each side, on a venue that does not net - (Polymarket). `None` elsewhere.""" entry_price: Decimal | None = None mark_price: Decimal | None = None unrealized_pnl: Decimal | None = None realized_pnl: Decimal | None = None + # Collateral locked against this position, where the venue reports it. margin: Decimal | None = None - """Collateral locked against this position, where the venue reports it.""" resolved: bool = False + # Resolved and past any dispute window. On Polymarket a resolved market + # can still be challenged; `resolved` without `final` says so. final: bool = False - """Resolved and past any dispute window. On Polymarket a resolved market - can still be challenged; `resolved` without `final` says so.""" won: bool | None = None payout: Decimal | None = None + # Payout waiting to be claimed on-chain. Polymarket only. redeemable: Decimal | None = None - """Payout waiting to be claimed on-chain. Polymarket only.""" timestamp: int | None = None info: dict[str, Any] = Field(default_factory=dict) @@ -528,8 +528,8 @@ class Settlement(_Base): venue: str account: Account | None = None market_id: str + # Which side was held into settlement, when the venue says. held: PositionSide | None = None - """Which side was held into settlement, when the venue says.""" result: str | None = None won: bool | None = None quantity: Decimal | None = None @@ -548,12 +548,12 @@ class Balance(_Base): currency: str total: Decimal available: Decimal + # Committed to resting orders and margin, where the venue reports it. + # `None` means it does not, not zero. locked: Decimal | None = None - """Committed to resting orders and margin, where the venue reports it. - `None` means it does not, not zero.""" + # The venue's own figure for what can still be bought, where it has one + # (Polymarket US). `None` means the venue defines none, not zero. buying_power: Decimal | None = None - """The venue's own figure for what can still be bought, where it has one - (Polymarket US). `None` means the venue defines none, not zero.""" timestamp: int | None = None info: dict[str, Any] = Field(default_factory=dict) @@ -568,12 +568,12 @@ class FeeEstimate(_Base): price: Decimal quantity: Decimal taker_fee: Decimal | None = None + # Negative where the venue pays makers. maker_fee: Decimal | None = None - """Negative where the venue pays makers.""" builder_fee: Decimal | None = None + # The least one taker order pays, where the venue has a floor (Opinion: + # 0.25 USDT), already applied to `taker_fee`. `None` means no floor. min_fee: Decimal | None = None - """The least one taker order pays, where the venue has a floor (Opinion: - 0.25 USDT), already applied to `taker_fee`. `None` means no floor.""" currency: str = "USD" info: dict[str, Any] = Field(default_factory=dict) @@ -595,38 +595,38 @@ def _positive(cls, value: Decimal) -> Decimal: class RfqStatus(str, Enum): OPEN = "open" + # No longer quotable; `Rfq.closed_reason` says why. CLOSED = "closed" - """No longer quotable; `Rfq.closed_reason` says why.""" class RfqCloseReason(str, Enum): + # Deleted by its creator. CANCELED = "canceled" - """Deleted by its creator.""" EXPIRED = "expired" + # A quote on it traded. EXECUTED = "executed" - """A quote on it traded.""" + # Closed by the venue because a newer RFQ took its place. REPLACED = "replaced" - """Closed by the venue because a newer RFQ took its place.""" class QuoteStatus(str, Enum): """`open` → `accepted` → `confirmed` → `executed`, or off the path to `canceled`, `expired` or `failed` before `executed`.""" + # Quotable: the requester may accept a side. OPEN = "open" - """Quotable: the requester may accept a side.""" + # The requester took a side; the maker has not confirmed. ACCEPTED = "accepted" - """The requester took a side; the maker has not confirmed.""" + # The maker confirmed (its last look); the venue will submit the orders. CONFIRMED = "confirmed" - """The maker confirmed (its last look); the venue will submit the orders.""" + # The orders were submitted. Not a fill: read the order's fills. EXECUTED = "executed" - """The orders were submitted. Not a fill: read the order's fills.""" + # The maker withdrew it, or its RFQ closed. CANCELED = "canceled" - """The maker withdrew it, or its RFQ closed.""" + # Not accepted, or not confirmed, by its deadline. EXPIRED = "expired" - """Not accepted, or not confirmed, by its deadline.""" + # The venue reported that the execution failed; nothing traded. FAILED = "failed" - """The venue reported that the execution failed; nothing traded.""" QUOTE_TERMINAL_STATUSES = frozenset({ @@ -663,12 +663,12 @@ def _leg_pair(outcome: str | None, action: str | None) -> None: class ComboLeg(_Base): """One market a combo needs, and which outcome of it.""" + # Synpath id, `venue:native`. market_id: str - """Synpath id, `venue:native`.""" + # The outcome this leg needs. On Polymarket US `no` is `SIDE_SELL`. outcome: Outcome - """The outcome this leg needs. On Polymarket US `no` is `SIDE_SELL`.""" + # The leg's event, where the venue wants it (Kalshi `event_ticker`). event_id: str | None = None - """The leg's event, where the venue wants it (Kalshi `event_ticker`).""" info: dict[str, Any] = Field(default_factory=dict) @@ -677,11 +677,11 @@ class Combo(_Base): leg does. Positions, P&L and settlement live on `market_id`, like any market's.""" + # The combo's own Synpath id (`kalshi:KXMVE…`, `polymarket_us:caoc-…`). market_id: str - """The combo's own Synpath id (`kalshi:KXMVE…`, `polymarket_us:caoc-…`).""" venue: str + # Kalshi's multivariate event collection the combo was built in. collection_id: str | None = None - """Kalshi's multivariate event collection the combo was built in.""" legs: list[ComboLeg] status: Literal["open", "closed", "settled"] | None = None created_at: int | None = None @@ -702,24 +702,24 @@ class ComboMarket(_Base): combo-eligible markets (Polymarket's catalogue, Polymarket US's `comboEnabled`). Kalshi lists eligibility by collection instead.""" + # Synpath id, `venue:native`: the leg's `ComboLeg.market_id`. market_id: str - """Synpath id, `venue:native`: the leg's `ComboLeg.market_id`.""" venue: str title: str | None = None + # The market's event, where the venue's list names it (Polymarket US). event_id: str | None = None - """The market's event, where the venue's list names it (Polymarket US).""" event_title: str | None = None + # Per outcome (`yes`, `no`), where a leg is named by a position id + # (Polymarket: `leg.info["position_id"]`). position_ids: dict[str, str] | None = None - """Per outcome (`yes`, `no`), where a leg is named by a position id - (Polymarket: `leg.info["position_id"]`).""" min_legs: int = 2 + # The most legs a combo may have on this venue, where documented. max_legs: int | None = None - """The most legs a combo may have on this venue, where documented.""" + # The venue's own market status, as listed. status: str | None = None - """The venue's own market status, as listed.""" tags: list[str] = Field(default_factory=list) + # The venue's own row. info: dict[str, Any] = Field(default_factory=dict) - """The venue's own row.""" class Rfq(_Base): @@ -729,27 +729,27 @@ class Rfq(_Base): id: str venue: str account: Account | None = None + # The single market asked about. Exactly one of this and `combo_id`. market_id: str | None = None - """The single market asked about. Exactly one of this and `combo_id`.""" + # The combo's own market id (`Combo.market_id`) when the RFQ is on a combo. combo_id: str | None = None - """The combo's own market id (`Combo.market_id`) when the RFQ is on a combo.""" + # The combo's legs, where known. combo: Combo | None = None - """The combo's legs, where known.""" outcome: Outcome | None = None + # The side asked for, where the venue asks one side (Polymarket global: + # YES, buy or sell). `None` on a two-sided request (Kalshi, Polymarket US). action: Action | None = None - """The side asked for, where the venue asks one side (Polymarket global: - YES, buy or sell). `None` on a two-sided request (Kalshi, Polymarket US).""" + # Contracts. Exactly one of this and `amount`. quantity: Decimal | None = None - """Contracts. Exactly one of this and `amount`.""" + # Money, in the venue's collateral (Kalshi `target_cost_dollars`, + # Polymarket US `cashOrderQty`). Not the old name for `quantity`. amount: Decimal | None = None - """Money, in the venue's collateral (Kalshi `target_cost_dollars`, - Polymarket US `cashOrderQty`). Not the old name for `quantity`.""" + # Leave what a quote does not fill resting on the book. rest_remainder: bool = False - """Leave what a quote does not fill resting on the book.""" status: RfqStatus = RfqStatus.OPEN closed_reason: RfqCloseReason | None = None + # Created by this account; `False` for an incoming RFQ a quoter sees. mine: bool = True - """Created by this account; `False` for an incoming RFQ a quoter sees.""" created_at: int | None = None expires_at: int | None = None updated_at: int | None = None @@ -788,27 +788,27 @@ class RfqQuote(_Base): rfq_id: str venue: str account: Account | None = None + # The instrument quoted (a market or a combo's own id), where the venue says. market_id: str | None = None - """The instrument quoted (a market or a combo's own id), where the venue says.""" bid: Decimal | None = None ask: Decimal | None = None bid_quantity: Decimal | None = None ask_quantity: Decimal | None = None status: QuoteStatus = QuoteStatus.OPEN + # The YES-leg side the requester took, as the venue reports it. accepted_side: Side | None = None - """The YES-leg side the requester took, as the venue reports it.""" + # When the maker must confirm (its last look) after an accept. confirm_by: int | None = None - """When the maker must confirm (its last look) after an accept.""" + # When the venue submits the orders after a confirm. execute_by: int | None = None - """When the venue submits the orders after a confirm.""" + # When the quote stops being acceptable. expires_at: int | None = None - """When the quote stops being acceptable.""" + # The requester's order once executed (Kalshi `rfq_creator_order_id`). taker_order_id: str | None = None - """The requester's order once executed (Kalshi `rfq_creator_order_id`).""" + # The quoter's order once executed (Kalshi `quote_creator_order_id`). maker_order_id: str | None = None - """The quoter's order once executed (Kalshi `quote_creator_order_id`).""" + # Made by this account (the quoter side); `False` for one we received. mine: bool = False - """Made by this account (the quoter side); `False` for one we received.""" created_at: int | None = None updated_at: int | None = None info: dict[str, Any] = Field(default_factory=dict) @@ -855,20 +855,20 @@ class AcceptQuote(_Base): """A requester taking one side of a quote. Becomes an `rfq_taker` order.""" quote_id: str + # Needed by venues that address a quote under its RFQ (Kalshi, Polymarket US). rfq_id: str | None = None - """Needed by venues that address a quote under its RFQ (Kalshi, Polymarket US).""" outcome: Outcome action: Action + # The worst YES price accepted: the highest for a YES buy (or NO sell), + # the lowest for a YES sell (or NO buy). A quote worse than it is refused + # before anything is sent. worst_price: Decimal | None = None - """The worst YES price accepted: the highest for a YES buy (or NO sell), - the lowest for a YES sell (or NO buy). A quote worse than it is refused - before anything is sent.""" quantity: Decimal | None = None + # A partial size, where the venue allows one (Kalshi FIX). Neither means + # the quote's full size. At most one. amount: Decimal | None = None - """A partial size, where the venue allows one (Kalshi FIX). Neither means - the quote's full size. At most one.""" + # The idempotency key; defaults to one derived from the quote. client_order_id: str | None = None - """The idempotency key; defaults to one derived from the quote.""" account: Account | None = None book: str | None = None trader: str | None = None @@ -896,18 +896,18 @@ class MakerQuote(_Base): """A quoter's answer to an incoming RFQ, in YES prices.""" rfq_id: str + # The YES price we buy at, if the requester sells. bid: Decimal | None = None - """The YES price we buy at, if the requester sells.""" + # The YES price we sell at, if the requester buys. ask: Decimal | None = None - """The YES price we sell at, if the requester buys.""" + # Contracts on each side; `None` means the RFQ's full size. quantity: Decimal | None = None - """Contracts on each side; `None` means the RFQ's full size.""" expires_at: int | None = None client_order_id: str | None = None rest_remainder: bool = False + # Our order, once the quote trades, only adds liquidity (Polymarket US + # `postOnly`, participate-don't-initiate). post_only: bool = False - """Our order, once the quote trades, only adds liquidity (Polymarket US - `postOnly`, participate-don't-initiate).""" account: Account | None = None book: str | None = None trader: str | None = None diff --git a/synpath/types.py b/synpath/types.py index 3233e1d..9aa95a7 100644 --- a/synpath/types.py +++ b/synpath/types.py @@ -29,29 +29,27 @@ from ._deprecated import QUANTITY, rename_input, renamed_fields +# Normalized lifecycle. `native_status` always carries the venue's own word. +# +# unopened — listed but not yet accepting orders +# open — trading +# closed — trading has stopped, outcome not yet final +# settled — outcome final and paid MarketStatus = Literal["unopened", "open", "closed", "settled"] -"""Normalized lifecycle. `native_status` always carries the venue's own word. - - unopened — listed but not yet accepting orders - open — trading - closed — trading has stopped, outcome not yet final - settled — outcome final and paid -""" +# Which side of a market a book or candle series is priced for. BookSide = Literal["yes", "no"] -"""Which side of a market a book or candle series is priced for.""" +# How a venue stores the book behind a market. +# +# shared_complement — one book serves both sides (Kalshi, Polymarket US). The +# NO view is a transform of the YES book, not a second book. +# native_per_outcome — each side owns an independently addressable book +# (Polymarket: one per CLOB token). BookModel = Literal["shared_complement", "native_per_outcome"] -"""How a venue stores the book behind a market. - - shared_complement — one book serves both sides (Kalshi, Polymarket US). The - NO view is a transform of the YES book, not a second book. - native_per_outcome — each side owns an independently addressable book - (Polymarket: one per CLOB token). -""" +# Where a candle's OHLC actually came from. See `Candle`. PriceSource = Literal["trade", "sampled_mid", "sampled_last", "bid_ask_mid"] -"""Where a candle's OHLC actually came from. See `Candle`.""" T = TypeVar("T") @@ -133,19 +131,19 @@ class Quote(_Base): one side that is quoted. """ + # Best bid, 0-1. `None` when nobody is bidding. bid: float | None = None - """Best bid, 0-1. `None` when nobody is bidding.""" bid_size: float | None = None + # Best ask, 0-1. `None` when nobody is offering. ask: float | None = None - """Best ask, 0-1. `None` when nobody is offering.""" ask_size: float | None = None + # `(bid + ask) / 2`, only when both sides are quoted. mid: float | None = None - """`(bid + ask) / 2`, only when both sides are quoted.""" + # Last traded price. `None` if this instrument has never traded. last: float | None = None - """Last traded price. `None` if this instrument has never traded.""" + # When `last` traded, in ms. Always read it alongside `last`: on a thin + # market the last print can be months old. last_timestamp: int | None = None - """When `last` traded, in ms. Always read it alongside `last`: on a thin - market the last print can be months old.""" last_datetime: str | None = None @computed_field # type: ignore[prop-decorator] @@ -172,17 +170,17 @@ class Outcome(_Base): book, which `Market.book_model` records. """ + # The venue's display text ("Yes", "No", "Up", a candidate name). Never + # used to decide financial logic: which of `market.yes` / `market.no` an + # outcome sits in is. label: str - """The venue's display text ("Yes", "No", "Up", a candidate name). Never - used to decide financial logic: which of `market.yes` / `market.no` an - outcome sits in is.""" + # In this outcome's own price convention: the NO quote is what NO costs. quote: Quote = Field(default_factory=Quote) - """In this outcome's own price convention: the NO quote is what NO costs.""" + # The venue's own id for this side, where it has one. Polymarket only: + # the CLOB token id the venue's order books and orders are keyed on. venue_token_id: str | None = None - """The venue's own id for this side, where it has one. Polymarket only: - the CLOB token id the venue's order books and orders are keyed on.""" + # Absolute probability delta over 24h, when the venue publishes it. price_change_24h: float | None = None - """Absolute probability delta over 24h, when the venue publishes it.""" info: dict[str, Any] = Field(default_factory=dict) @@ -200,89 +198,89 @@ class MarketStats(_Base): open_interest: float | None = None volume_unit: Literal["contracts", "collateral"] | None = None liquidity_unit: Literal["contracts", "collateral"] | None = None + # When the venue says these numbers were current, in ms. as_of: int | None = None - """When the venue says these numbers were current, in ms.""" class Market(_Base): """A single settleable contract — the thing that actually resolves.""" + # Synpath's id: `venue:native`, e.g. `kalshi:KXFEDDECISION-26SEP-C25` or + # `polymarket:2252244`. One per listing on one venue. The part after the + # colon is the venue's own id, also on `venue_market_id`. id: str - """Synpath's id: `venue:native`, e.g. `kalshi:KXFEDDECISION-26SEP-C25` or - `polymarket:2252244`. One per listing on one venue. The part after the - colon is the venue's own id, also on `venue_market_id`.""" venue: str + # The venue's own id, unprefixed: a Kalshi ticker, a Polymarket Gamma id, + # a Polymarket US slug. venue_market_id: str - """The venue's own id, unprefixed: a Kalshi ticker, a Polymarket Gamma id, - a Polymarket US slug.""" + # The parent event's Synpath id, `venue:native`. event_id: str | None = None - """The parent event's Synpath id, `venue:native`.""" title: str + # Resolution criteria, verbatim from the venue. description: str | None = None - """Resolution criteria, verbatim from the venue.""" slug: str | None = None + # The YES side, by position in the venue's payload rather than by label + # text: some Kalshi markets label both sides identically. yes: Outcome - """The YES side, by position in the venue's payload rather than by label - text: some Kalshi markets label both sides identically.""" no: Outcome status: MarketStatus + # The venue's own status word, untranslated. native_status: str | None = None - """The venue's own status word, untranslated.""" + # Accepting orders right now. Derived, and deliberately separate from + # `status`: a venue can halt trading without changing lifecycle state. active: bool = False - """Accepting orders right now. Derived, and deliberately separate from - `status`: a venue can halt trading without changing lifecycle state.""" market_type: Literal["binary", "categorical", "scalar", "unknown"] = "binary" open_timestamp: int | None = None close_timestamp: int | None = None + # The venue's *scheduled* resolution time, not when it actually resolved. resolution_timestamp: int | None = None - """The venue's *scheduled* resolution time, not when it actually resolved.""" open_datetime: str | None = None close_datetime: str | None = None resolution_datetime: str | None = None + # Minimum price increment. Needed to place an order that will be accepted. tick_size: float | None = None - """Minimum price increment. Needed to place an order that will be accepted.""" + # What one contract pays at full settlement. Both venues pay 1.00 today. + # Every complement transform reads this rather than hardcoding 1. face_value: float = 1.0 - """What one contract pays at full settlement. Both venues pay 1.00 today. - Every complement transform reads this rather than hardcoding 1.""" book_model: BookModel = "native_per_outcome" stats: MarketStats = Field(default_factory=MarketStats) url: str | None = None image_url: str | None = None category: str | None = None tags: list[str] = Field(default_factory=list) + # The venue's recurring-series tier above the event. Kalshi keys its fee + # schedule on this. `None` on venues without the concept. series_id: str | None = None - """The venue's recurring-series tier above the event. Kalshi keys its fee - schedule on this. `None` on venues without the concept.""" + # This market's short name inside its event. + # + # An event like "Fed Decision in September?" holds one market per outcome, + # and `title` is the whole question ("Will the Fed decrease interest rates by + # 50+ bps after the September meeting?") while this is the label the venue + # lists it under ("50+ bps decrease"). Kalshi publishes it as the YES side's + # subtitle, Polymarket as the market's group item title. Useful for display, + # and for deciding whether two venues are offering the same option. outcome_label: str | None = None - """This market's short name inside its event. - - An event like "Fed Decision in September?" holds one market per outcome, - and `title` is the whole question ("Will the Fed decrease interest rates by - 50+ bps after the September meeting?") while this is the label the venue - lists it under ("50+ bps decrease"). Kalshi publishes it as the YES side's - subtitle, Polymarket as the market's group item title. Useful for display, - and for deciding whether two venues are offering the same option.""" + # Whether this market belongs to a group where a NO position converts into + # YES exposure on the others. `None` means unknown, never assumed False. neg_risk: bool | None = None - """Whether this market belongs to a group where a NO position converts into - YES exposure on the others. `None` means unknown, never assumed False.""" + # Who decides the outcome, as `{"name", "url"}` entries. + # + # Carried because `description` says what has to happen and this says who + # rules on whether it did. Two venues can list the same question and settle + # it off different sources, which is the difference between the same trade + # and two different ones. Empty when the venue names no source. settlement_sources: list[dict[str, Any]] = Field(default_factory=list) - """Who decides the outcome, as `{"name", "url"}` entries. - - Carried because `description` says what has to happen and this says who - rules on whether it did. Two venues can list the same question and settle - it off different sources, which is the difference between the same trade - and two different ones. Empty when the venue names no source.""" info: dict[str, Any] = Field(default_factory=dict) class Event(_Base): """A real-world question domain grouping one or more markets.""" + # Synpath's id: `venue:native`, e.g. `kalshi:KXFEDDECISION-26SEP`. id: str - """Synpath's id: `venue:native`, e.g. `kalshi:KXFEDDECISION-26SEP`.""" venue: str + # The venue's own id, unprefixed. venue_event_id: str - """The venue's own id, unprefixed.""" title: str description: str | None = None slug: str | None = None @@ -292,21 +290,21 @@ class Event(_Base): category: str | None = None tags: list[str] = Field(default_factory=list) series_id: str | None = None + # `None` is a real answer: the venue did not say. Never defaulted to False. mutually_exclusive: bool | None = None - """`None` is a real answer: the venue did not say. Never defaulted to False.""" close_timestamp: int | None = None close_datetime: str | None = None url: str | None = None image_url: str | None = None + # Who decides the outcomes under this event. On Kalshi these are published + # per event rather than per market, so a market inherits its event's. settlement_sources: list[dict[str, Any]] = Field(default_factory=list) - """Who decides the outcomes under this event. On Kalshi these are published - per event rather than per market, so a market inherits its event's.""" info: dict[str, Any] = Field(default_factory=dict) class OrderLevel(_Base): + # 0-1, in the price convention of the side the book was asked for. price: float - """0-1, in the price convention of the side the book was asked for.""" size: float @@ -319,21 +317,21 @@ class OrderBook(_Base): rather than invisible. """ + # Synpath id, `venue:native`. market_id: str - """Synpath id, `venue:native`.""" + # Which side this book is priced for. Ask for `side="no"` to see what NO + # costs; on a `shared_complement` venue that is the YES book mirrored. side: BookSide = "yes" - """Which side this book is priced for. Ask for `side="no"` to see what NO - costs; on a `shared_complement` venue that is the YES book mirrored.""" venue: str + # Descending by price. bids: list[OrderLevel] = Field(default_factory=list) - """Descending by price.""" + # Ascending by price. asks: list[OrderLevel] = Field(default_factory=list) - """Ascending by price.""" timestamp: int | None = None datetime: str | None = None book_model: BookModel = "native_per_outcome" + # True when these levels were mirrored from the complement's book. derived: bool = False - """True when these levels were mirrored from the complement's book.""" depth_scope: Literal["full", "top_n", "unknown"] = "unknown" info: dict[str, Any] = Field(default_factory=dict) @@ -356,18 +354,18 @@ class Trade(_Base): """One execution, from the taker's point of view.""" id: str + # Synpath id, `venue:native`. market_id: str - """Synpath id, `venue:native`.""" timestamp: int datetime: str + # Always the YES price, 0-1. A trade where the taker bought NO at 0.30 is + # reported as `price=0.70, side="sell"`. price: float - """Always the YES price, 0-1. A trade where the taker bought NO at 0.30 is - reported as `price=0.70, side="sell"`.""" + # Contracts traded. quantity: float - """Contracts traded.""" + # What the taker did on the YES leg: `buy` took YES, `sell` took NO. + # `unknown` when the venue does not say. side: Literal["buy", "sell", "unknown"] = "unknown" - """What the taker did on the YES leg: `buy` took YES, `sell` took NO. - `unknown` when the venue does not say.""" info: dict[str, Any] = Field(default_factory=dict) @@ -397,9 +395,9 @@ class HistoricalTrade(Trade): """A Trade plus its recorder receive time and timestamp provenance.""" __synpath_renamed__ = {} # the hosted service's `amount` is no deprecation: read it quietly + # Contracts traded. The hosted history service names it `amount`; both + # names are read, and it is always written as `quantity`. quantity: float = Field(validation_alias=AliasChoices("quantity", "amount")) - """Contracts traded. The hosted history service names it `amount`; both - names are read, and it is always written as `quantity`.""" observed_at_ms: int timestamp_source: Literal["venue", "recorder_fallback"] @@ -480,39 +478,37 @@ class Candle(_Base): are holding. """ + # Start of the bar, in ms. timestamp: int - """Start of the bar, in ms.""" datetime: str open: float | None = None high: float | None = None low: float | None = None close: float | None = None + # `None` when the venue publishes no volume for the bar, which is not the + # same as zero volume. volume: float | None = None - """`None` when the venue publishes no volume for the bar, which is not the - same as zero volume.""" trade_count: int | None = None + # trade — built from executions in the period + # bid_ask_mid — no trades; midpoint of the venue's bid/ask bars + # sampled_mid — the venue published price samples, not bars, and these were + # bucketed by this library (Polymarket) + # sampled_last — the same, but the samples are the last traded price, not a + # midpoint: a period without trades repeats the last one (Opinion) price_source: PriceSource = "trade" - """ - trade — built from executions in the period - bid_ask_mid — no trades; midpoint of the venue's bid/ask bars - sampled_mid — the venue published price samples, not bars, and these were - bucketed by this library (Polymarket) - sampled_last — the same, but the samples are the last traded price, not a - midpoint: a period without trades repeats the last one (Opinion) - """ bid_close: float | None = None + # Book state at the close of the bar, where the venue reports it. ask_close: float | None = None - """Book state at the close of the bar, where the venue reports it.""" info: dict[str, Any] = Field(default_factory=dict) +# Kalshi's quadratic fee types: the maker fee as a share of the taker +# coefficient (0.07). A plain `quadratic` series charges makers nothing. KALSHI_MAKER_SHARE: dict[str, float] = { "quadratic": 0.0, "quadratic_with_maker_fees": 0.25, "quadratic_with_combo_maker_fees": 0.5, } -"""Kalshi's quadratic fee types: the maker fee as a share of the taker -coefficient (0.07). A plain `quadratic` series charges makers nothing.""" class FeeSchedule(_Base): @@ -526,19 +522,19 @@ class FeeSchedule(_Base): venue: str scope: Literal["venue", "series", "market"] scope_id: str + # Kalshi: `quadratic` — fee per contract is `multiplier * p * (1 - p)`, + # which peaks at 50c and vanishes at the extremes. fee_type: str - """Kalshi: `quadratic` — fee per contract is `multiplier * p * (1 - p)`, - which peaks at 50c and vanishes at the extremes.""" multiplier: float | None = None maker_rate: float | None = None taker_rate: float | None = None rounding: str | None = None + # Polymarket: the power applied to `P * (1 - P)`. Every live market + # publishes `1`; `None` means 1. exponent: float | None = None - """Polymarket: the power applied to `P * (1 - P)`. Every live market - publishes `1`; `None` means 1.""" + # Smallest fee one taker order is charged, in collateral, where the venue + # has a floor (Opinion: 0.25 USDT). `None` means no floor. min_fee: float | None = None - """Smallest fee one taker order is charged, in collateral, where the venue - has a floor (Opinion: 0.25 USDT). `None` means no floor.""" info: dict[str, Any] = Field(default_factory=dict) def estimate(self, price: float, contracts: float, *, taker: bool = True) -> float | None: @@ -627,11 +623,11 @@ def estimate(self, price: float, contracts: float, *, taker: bool = True) -> flo LIMITLESS_FEE_CEILING = 0.03 +# Limitless's published buy-fee points: (price, rate). Flat at 3% below 50c. LIMITLESS_BUY_CURVE: tuple[tuple[float, float], ...] = ( (0.50, 0.0300), (0.55, 0.0252), (0.60, 0.0213), (0.65, 0.0180), (0.70, 0.0151), (0.75, 0.0126), (0.80, 0.0105), (0.85, 0.0085), (0.90, 0.0068), (0.95, 0.0053), (0.99, 0.0042), (0.999, 0.0040), ) -"""Limitless's published buy-fee points: (price, rate). Flat at 3% below 50c.""" def limitless_buy_rate(price: float) -> float: @@ -655,26 +651,26 @@ class MarketLink(_Base): or "not_same" this library hands down. See `match_market`. """ + # A Synpath id (`polymarket:2252244`) -- the other venue's listing. id: str - """A Synpath id (`polymarket:2252244`) -- the other venue's listing.""" venue: str + # Which side of *this* link is which side of the anchor: `{"yes": "yes", + # "no": "no"}` when the two agree, `{"yes": "no", "no": "yes"}` when the + # venues put the proposition on opposite sides (Kalshi's "Mashtakov wins?" + # YES is Polymarket's "Pieczonka / Mashtakov" NO). side_map: dict[BookSide, BookSide] - """Which side of *this* link is which side of the anchor: `{"yes": "yes", - "no": "no"}` when the two agree, `{"yes": "no", "no": "yes"}` when the - venues put the proposition on opposite sides (Kalshi's "Mashtakov wins?" - YES is Polymarket's "Pieczonka / Mashtakov" NO).""" class MarketMatch(_Base): """The answer to "what is this market on the other venue?" -- see `match_market`.""" + # The Synpath id the query was anchored on. anchor: str - """The Synpath id the query was anchored on.""" + # The canonical event both sides of a match belong to, when the anchor parsed onto one. event_id: str | None = None - """The canonical event both sides of a match belong to, when the anchor parsed onto one.""" + # `None` means the anchor is a real listing with nothing on the other + # venue asking the same question -- not "unknown", not "no such market". matched: MarketLink | None = None - """`None` means the anchor is a real listing with nothing on the other - venue asking the same question -- not "unknown", not "no such market".""" class EventMatch(_Base): @@ -686,11 +682,11 @@ class EventMatch(_Base): """ anchor: str + # The canonical event(s) the anchor belongs to. Usually one. event_ids: list[str] = Field(default_factory=list) - """The canonical event(s) the anchor belongs to. Usually one.""" + # Venue name -> its native event ids on the same canonical event(s), or + # `None` when that venue lists nothing there. events: dict[str, list[str] | None] = Field(default_factory=dict) - """Venue name -> its native event ids on the same canonical event(s), or - `None` when that venue lists nothing there.""" class Series(_Base): diff --git a/tests/serve_harness.py b/tests/serve_harness.py index dadd810..be845f1 100644 --- a/tests/serve_harness.py +++ b/tests/serve_harness.py @@ -52,8 +52,8 @@ def test_something(serve_harness): POSTGRES_DSN_VARIABLE = "SYNPATH_TEST_POSTGRES_DSN" KALSHI_TRADE_API = "https://external-api.kalshi.com/trade-api/v2" +# Venues whose market stream needs no credentials; the fake factory opens these by default. PUBLIC_STREAM_VENUES = ("polymarket", "hyperliquid", "limitless") -"""Venues whose market stream needs no credentials; the fake factory opens these by default.""" VENUES_WITHOUT_TRADE_STREAMS = ("limitless", "predict_fun") @@ -89,12 +89,12 @@ def __init__(self, *, book: dict[str, list[list[str]]] | None = None, fill_on_cr self.book = book or {"yes_dollars": [["0.40", "10"]], "no_dollars": [["0.55", "7"]]} self.fill_on_create = fill_on_create self.lose_create_response = lose_create_response + # Venue order records by order id, in Kalshi's own shape. self.orders: dict[str, dict[str, Any]] = {} - """Venue order records by order id, in Kalshi's own shape.""" self.creates: list[dict[str, Any]] = [] self.cancels: list[str] = [] + # Order ids the venue answers 404 for on every route: no record at all. self.forgotten: set[str] = set() - """Order ids the venue answers 404 for on every route: no record at all.""" def fill(self, order_id: str) -> None: """Execute a resting order at the venue, wholly, without a stream update.""" @@ -329,14 +329,14 @@ def __init__(self, tmp_path: Path, *, storage: str = "sqlite", postgres_dsn: str self.postgres_schema = "synpath_harness_" + uuid.uuid4().hex if storage == "postgresql" else None self.stream_venues = set(stream_venues) self.kalshi = kalshi or MockKalshi() + # Read adapters (or stand-ins) for venues other than the mocked Kalshi. self.exchanges = dict(exchanges or {}) - """Read adapters (or stand-ins) for venues other than the mocked Kalshi.""" self.hosted = hosted or MockHosted() + # Trading adapters for venues other than the mocked Kalshi (a `PaperVenue`, say). self.trading = dict(trading or {}) - """Trading adapters for venues other than the mocked Kalshi (a `PaperVenue`, say).""" self.engine_stream = FakeVenueStream("kalshi", private=True, unwatch=False) if engine_stream else None + # Market streams the hub opened itself, by venue, oldest first. self.opened: dict[str, list[FakeVenueStream]] = {} - """Market streams the hub opened itself, by venue, oldest first.""" self.config = {"risk": {"price_collar": None, "duplicate_window_ms": 0, "closing_soon_s": None}, "book_interval_s": 0, **(config or {})} self.portal: Any = None diff --git a/tests/test_combo_warmer.py b/tests/test_combo_warmer.py index e573cd1..d8f40f6 100644 --- a/tests/test_combo_warmer.py +++ b/tests/test_combo_warmer.py @@ -74,8 +74,8 @@ def __init__(self, pages: int, per_page: int = 10, **kw: Any): self.reads = 0 self.fail = False self.hold: asyncio.Event | None = None + # Hold the read of this page (1-based) until `hold` is set. self.block_at: int | None = None - """Hold the read of this page (1-based) until `hold` is set.""" async def fetch_combo_eligible_markets(self, *, event_id=None, tag=None, limit=None, cursor=None) -> Page: if self.hold is not None and (self.block_at is None or self.reads + 1 == self.block_at): diff --git a/tests/test_hyperliquid_trading.py b/tests/test_hyperliquid_trading.py index c333f97..39fc0f7 100644 --- a/tests/test_hyperliquid_trading.py +++ b/tests/test_hyperliquid_trading.py @@ -18,8 +18,8 @@ from conftest import load +# The private key the venue's Python SDK signs its test vectors with. SDK_KEY = "0x0123456789012345678901234567890123456789012345678901234567890123" -"""The private key the venue's Python SDK signs its test vectors with.""" def sdk_order(**extra): diff --git a/tests/test_server.py b/tests/test_server.py index 86066cd..4633269 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -83,8 +83,8 @@ def fetch_trades(self, market_id, *, since=None, limit=None, cursor=None): next_cursor="100", ) + # Five hourly bars the stub's market has. BARS = [1_700_000_000_000 + n * 3_600_000 for n in range(5)] - """Five hourly bars the stub's market has.""" def fetch_ohlcv(self, market_id, *, timeframe="1h", since=None, until=None, limit=None): self._check("fetch_ohlcv", market_id=market_id, timeframe=timeframe, since=since, until=until, limit=limit) From 7a71bd9aa31ebe5adb958da2f2d8384f2140f3ed Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 2 Nov 2026 10:44:00 +0000 Subject: [PATCH 2/2] refactor(stream): clarify lifecycle and centralize raw-frame handling --- docs/streaming.md | 36 ++++ synpath/ws/base.py | 241 +++++++++++++++++++-------- synpath/ws/betfair.py | 48 +++--- synpath/ws/grpc.py | 57 ++++--- synpath/ws/hyperliquid.py | 35 ++-- synpath/ws/kalshi.py | 36 ++-- synpath/ws/limitless.py | 18 +- synpath/ws/opinion.py | 56 ++++--- synpath/ws/polymarket.py | 40 +++-- synpath/ws/polymarket_us.py | 29 +++- synpath/ws/polymarket_us_exchange.py | 20 +-- synpath/ws/predict_fun.py | 33 ++-- synpath/ws/smarkets.py | 26 +-- synpath/ws/sx_bet.py | 30 ++-- tests/test_betfair_ws.py | 36 ++-- tests/test_core_streams.py | 46 ++--- tests/test_hyperliquid_ws.py | 32 ++-- tests/test_limitless_ws.py | 2 +- tests/test_opinion_ws.py | 40 ++--- tests/test_predict_fun_ws.py | 27 +-- tests/test_smarkets_ws.py | 30 ++-- tests/test_sx_bet_parlays.py | 2 +- tests/test_sx_bet_ws.py | 22 +-- tests/test_ws.py | 124 +++++++++----- 24 files changed, 670 insertions(+), 396 deletions(-) diff --git a/docs/streaming.md b/docs/streaming.md index bbbbfea..65d2167 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -22,6 +22,42 @@ async def main(): asyncio.run(main()) ``` +## Stream lifecycle and adapter hooks + +`start()` schedules one background receiver and returns the stream immediately. +It needs a running asyncio loop. It does not wait for an open connection, +completed subscriptions or a ready book. `async with stream` starts receiving +on entry and closes on exit; `async for event in stream` starts receiving if +needed and removes events from the stream's single queue. Multiple readers +share that queue rather than each receiving a copy. Closing ends iteration +when the queued shutdown marker reaches the reader. + +For tests or a custom transport, pass +`websocket_connection_opener=async_opener`. The opener is called on every +connection attempt, including reconnects, with the URL and +`additional_headers=stream.headers()`. It must return a connected socket +supporting the adapter's send, receive and close operations. The default +opener remains unchanged. + +For custom WebSocket adapters, implement `handle_raw(raw: str) -> list[Event]`. +The adapter owns framing, decoding, cached-state updates and any native/Python +selection. Return an empty list for a handled frame with no events; never +return `None`. The shared receiver counts frames and emits the returned +events. gRPC adapters retain their decoded-message `handle()` hook on +`GrpcStream`. + +Migration on the experimental SDK branch: + +- Rename constructor argument `connect` to `websocket_connection_opener`. +- Move a WebSocket subclass's old `handle(message)` logic behind `handle_raw()`; + the base class no longer decodes JSON or chooses a fallback handler. +- Replace calls to the removed `status()` helper with the appropriate private + `_record_*_and_emit_status()` operation. These operations own the related + counters and lifecycle state. `_emit_status_event()` only publishes a status. +- Instrumentation overriding private hooks must follow `_task` → + `_receiver_task`, `_run()` → `_run_connection_and_receive_loop()` and + `_dispatch()` → `_process_raw_frame_and_emit_events()`. + ## Streams | Stream | Endpoint | Credentials | Carries | diff --git a/synpath/ws/base.py b/synpath/ws/base.py index ef0d02a..415710c 100644 --- a/synpath/ws/base.py +++ b/synpath/ws/base.py @@ -45,6 +45,18 @@ ONE = Decimal("1") +def decode_json_or_text(raw: str) -> Any: + """Decode JSON while preserving text frames such as heartbeat responses. + + This is an optional utility for adapters, not a parsing policy imposed by + Stream. Wire protocols such as Socket.IO use their own frame decoder. + """ + try: + return json.loads(raw) + except ValueError: + return raw + + def now_ms() -> int: return int(time.time() * 1000) @@ -56,8 +68,8 @@ def now_ms() -> int: @dataclass(frozen=True, slots=True) class BookLevel: price: Decimal + # For a delta, the level's size after the change; zero means removed. size: Decimal - """For a delta, the level's size after the change; zero means removed.""" @dataclass(frozen=True, slots=True, kw_only=True) @@ -73,8 +85,8 @@ class BookEvent(Event): the terms of `side`: a NO book is what NO costs. `best_bid` and `best_ask` are the local book's after the change.""" + # Synpath id, `venue:native`. market_id: str - """Synpath id, `venue:native`.""" side: Literal["yes", "no"] = "yes" kind: Literal["snapshot", "delta"] bids: tuple[BookLevel, ...] = () @@ -266,8 +278,8 @@ def mirrored(self, face_value: Decimal = ONE) -> "PyLocalBook": return other +# The book every stream keeps: the Rust core's when installed, else `PyLocalBook`. LocalBook = _native.pick("LocalBook", PyLocalBook) -"""The book every stream keeps: the Rust core's when installed, else `PyLocalBook`.""" def D(value: Any) -> Decimal: @@ -288,8 +300,10 @@ def maybe_D(value: Any) -> Decimal | None: # Stream # --------------------------------------------------------------------------- -Connector = Callable[..., Awaitable[Any]] +WebSocketConnectionOpener = Callable[..., Awaitable[Any]] +# A unique queue marker, not a venue event. close() adds it after pending +# events so events() can stop instead of waiting forever for another message. _CLOSED = object() @@ -308,25 +322,30 @@ class Stream: """One venue WebSocket, kept alive, turned into events. Subclasses provide the endpoint (`url`, `headers()`), what to send after - connecting (`on_connect()`), how to read a message (`handle()`), and what + connecting (`on_connect()`), how to process a frame (`handle_raw()`), and what to forget when the connection drops (`on_disconnect()`). Subscribing is recording an intent: it is sent now if connected and again after every reconnect. + + websocket_connection_opener optionally supplies an async function that + opens a socket from the endpoint URL and additional_headers. It is called + for every connection attempt, including reconnects. Tests can supply + scripted sockets without replacing the venue adapter or receiver loop. """ venue: str = "" name: str = "stream" has: dict[str, Capability] = {} + # Carries account data the venue cannot replay after a disconnect. private: bool = False - """Carries account data the venue cannot replay after a disconnect.""" + # A text frame the venue wants as its heartbeat, sent every `ping_interval`. app_ping: str | None = None - """A text frame the venue wants as its heartbeat, sent every `ping_interval`.""" + # Whether the venue's keepalive arrives as data this stream can see (a + # `PONG` text frame, a heartbeat message). Only then does silence mean a + # dead connection: a venue that keeps the socket alive with protocol ping + # frames can be quiet for minutes on a slow market, and there the + # WebSocket library's own ping/pong is what detects a dead peer. data_heartbeat: bool = False - """Whether the venue's keepalive arrives as data this stream can see (a - `PONG` text frame, a heartbeat message). Only then does silence mean a - dead connection: a venue that keeps the socket alive with protocol ping - frames can be quiet for minutes on a slow market, and there the - WebSocket library's own ping/pong is what detects a dead peer.""" def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) @@ -336,7 +355,7 @@ def __init__( self, url: str, *, - connect: Connector | None = None, + websocket_connection_opener: WebSocketConnectionOpener | None = None, ping_interval: float = 10.0, idle_timeout: float | None | Literal["auto"] = "auto", backoff_initial: float = 0.5, @@ -344,18 +363,32 @@ def __init__( max_queue: int = 0, ): self.url = url - self._connect = connect + # Optional async function used instead of the normal WebSocket opener. + # Called on every connection attempt, including reconnects; tests can + # return scripted sockets while exercising the real stream lifecycle. + self._websocket_connection_opener = websocket_connection_opener self.ping_interval = ping_interval self.idle_timeout = (60.0 if self.data_heartbeat else None) if idle_timeout == "auto" else idle_timeout self.backoff_initial = backoff_initial self.backoff_max = backoff_max + # The receiver puts interpreted events here; events() removes them. + # One queue delivers each item to one reader, rather than broadcasting. self._queue: asyncio.Queue = asyncio.Queue(maxsize=max_queue) self._last_seen = 0 self._stale = "" self._ws: Any = None - self._task: asyncio.Task | None = None + # Background connection/receive/reconnect loop created by start(). + # Runs _run_connection_and_receive_loop(), not the caller's strategy loop. + # None means no task has been created, or close() has cleared it. + self._receiver_task: asyncio.Task | None = None + # A deliberate shutdown must stop reconnecting and prevent events() + # from automatically starting another receiver. self._closing = False + # Named status operations own lifecycle counters; frame reception owns + # message counts/timestamps. Neither establishes book readiness. self.stats = StreamStats() + # A waitable signal for an open connection. Set before on_connect(), + # so it does not establish subscription completion or book readiness. self.connected = asyncio.Event() # -- venue hooks ---------------------------------------------------------- @@ -370,22 +403,32 @@ async def on_connect(self) -> None: def on_disconnect(self) -> None: """Forget connection-scoped state: subscription ids, sequence numbers, book readiness.""" - def handle(self, message: Any) -> list[Event]: - """One decoded message as events.""" - return [] + def handle_raw(self, raw: str) -> list[Event]: + """Process one complete frame and return its SDK events, possibly none. - def handle_raw(self, raw: str) -> list[Event] | None: - """One undecoded frame as events, by the Rust core, or `None` to have - `handle` read it instead. A stream overrides this where the core - handles its messages; whatever the core declines goes to `handle`.""" - return None + The venue adapter owns framing, decoding, derived state updates and + any native/Python selection. It must return a list, never None. + """ + raise NotImplementedError("WebSocket adapters must implement handle_raw()") # -- lifecycle ------------------------------------------------------------ def start(self) -> "Stream": - if self._task is None or self._task.done(): + """Schedule the background receiver without waiting for a connection. + + Call inside a running asyncio event loop. Repeated calls share a + running receiver task; a missing or finished task is replaced. + Connection failures are retried without a configured attempt limit + until shutdown. Returning self supports async context-manager entry. + + This does not wait for connection, subscription completion or a ready + book. Consumers must use the appropriate connection/readiness signals. + """ + if self._receiver_task is None or self._receiver_task.done(): self._closing = False - self._task = asyncio.get_running_loop().create_task(self._run(), name=f"synpath-{self.venue}-{self.name}") + self._receiver_task = asyncio.get_running_loop().create_task( + self._run_connection_and_receive_loop(), name=f"synpath-{self.venue}-{self.name}", + ) return self async def close(self) -> None: @@ -396,31 +439,45 @@ async def close(self) -> None: await ws.close() except Exception: # pragma: no cover - closing a dead socket pass - if self._task is not None: - self._task.cancel() + if self._receiver_task is not None: + self._receiver_task.cancel() try: - await self._task + await self._receiver_task except (asyncio.CancelledError, Exception): pass - self._task = None + self._receiver_task = None self._queue.put_nowait(_CLOSED) async def __aenter__(self) -> "Stream": + """Enter `async with stream`: start receiving and bind self after `as`.""" return self.start() async def __aexit__(self, *exc: Any) -> None: + """Leave `async with`: close even if the block raises an exception. + + Returning None leaves the exception to propagate to the caller. + """ await self.close() def __aiter__(self) -> AsyncIterator[Event]: + """Give `async for event in stream` its asynchronous event iterator.""" return self.events() async def events(self) -> AsyncIterator[Event]: - """Every event, in the order it was produced, until `close()`. Starts - the stream if it has not been started; a closed stream stays closed.""" - if self._task is None and not self._closing: + """Yield queued events in production order, waiting when none are ready. + + Start the background receiver if it has not been started. After + close(), do not automatically restart it; finish when the shutdown + marker reaches this reader. Queue items are removed, not broadcast. + """ + # Allow direct iteration without requiring a separate start() call. + if self._receiver_task is None and not self._closing: self.start() while True: + # Waiting suspends this consumer, allowing the receiver and other + # coroutines to run. The returned item has been removed from the queue. item = await self._queue.get() + # Identity distinguishes our shutdown marker from every real event. if item is _CLOSED: return yield item @@ -428,13 +485,58 @@ async def events(self) -> AsyncIterator[Event]: def emit(self, event: Event) -> None: self._queue.put_nowait(event) - def status(self, state: StreamState, detail: str = "", *, key: str | None = None, reconcile: bool = False) -> None: - if state == "gap": - self.stats.gaps += 1 - elif state == "resynced": - self.stats.resyncs += 1 - elif state == "error": - self.stats.errors += 1 + # -- status operations ---------------------------------------------------- + + def _record_connection_opened_and_emit_status(self, *, reconcile_on_reconnect: bool | None = None) -> None: + """Count one established connection, wake waiters and publish its status. + + The caller decides when the transport is established: an open socket + for WebSockets, the first response/headers for gRPC. This does not + establish subscription completion or book readiness. + """ + reconnect = self.stats.connects > 0 + self.stats.connects += 1 + self.connected.set() + needs_reconciliation = self.private if reconcile_on_reconnect is None else reconcile_on_reconnect + self._emit_status_event( + "connected", "reconnected" if reconnect else "", reconcile=reconnect and needs_reconciliation, + ) + + def _record_connection_closed_and_emit_status(self, detail: str, *, notify_consumers: bool = True) -> None: + """Clear the connection, count its end and invalidate venue state before notifying. + + A deliberate shutdown or cancelled receiver still records the end and + runs on_disconnect(), but the caller suppresses the disconnect event. + Only call this for a connection previously recorded as established. + """ + self._ws = None + self.connected.clear() + self.stats.disconnects += 1 + self.on_disconnect() + if notify_consumers: + self._emit_status_event("disconnected", detail) + + def _record_gap_and_emit_status(self, detail: str = "", *, key: str | None = None, reconcile: bool = False) -> None: + """Count and report missing data; the venue adapter owns its recovery policy.""" + self.stats.gaps += 1 + self._emit_status_event("gap", detail, key=key, reconcile=reconcile) + + def _record_resync_and_emit_status(self, detail: str = "", *, key: str | None = None, reconcile: bool = False) -> None: + """Count and report a recovery already performed by the venue adapter.""" + self.stats.resyncs += 1 + self._emit_status_event("resynced", detail, key=key, reconcile=reconcile) + + def _record_error_and_emit_status(self, detail: str = "", *, key: str | None = None, reconcile: bool = False) -> None: + """Count and report an error without changing connection or book readiness.""" + self.stats.errors += 1 + self._emit_status_event("error", detail, key=key, reconcile=reconcile) + + def _emit_status_event(self, state: StreamState, detail: str = "", *, key: str | None = None, reconcile: bool = False) -> None: + """Construct and enqueue a status event without mutating lifecycle state or counters. + + Venue subclasses can redact sensitive details here so all status + operations, including connection failures, use the same protection. + """ self.emit(StreamStatusEvent( venue=self.venue, stream=self.name, state=state, detail=detail, key=key, reconcile_required=reconcile, )) @@ -448,12 +550,17 @@ async def send(self, frame: Any) -> bool: return True def backoff(self, attempt: int) -> float: + """Calculate a randomized retry delay in seconds; do not sleep. + + attempt is the zero-based consecutive failure index. backoff_max caps + the delay, not the number of retries; there is no retry budget here. + """ base = min(self.backoff_max, self.backoff_initial * (2 ** attempt)) return base * (0.5 + random.random() / 2) async def _open(self) -> Any: - if self._connect is not None: - return await self._connect(self.url, additional_headers=self.headers()) + if self._websocket_connection_opener is not None: + return await self._websocket_connection_opener(self.url, additional_headers=self.headers()) import websockets return await websockets.connect( @@ -461,40 +568,48 @@ async def _open(self) -> Any: ping_interval=None if self.app_ping else 20, max_size=None, ) - async def _run(self) -> None: + async def _run_connection_and_receive_loop(self) -> None: + """Maintain a connection and process incoming frames until shutdown.""" attempt = 0 + # The outer loop manages successive connections. Failed opens retry + # without an attempt limit; shutdown/cancellation stops the receiver. while not self._closing: try: ws = await self._open() except asyncio.CancelledError: raise except Exception as exc: - self.status("connect_failed", f"{type(exc).__name__}: {exc}") + self._emit_status_event("connect_failed", f"{type(exc).__name__}: {exc}") await asyncio.sleep(self.backoff(attempt)) attempt += 1 continue attempt = 0 + # Record an open transport and notify waiters/consumers. Subscription + # setup happens below, so "connected" does not mean market data is ready. self._ws = ws - reconnect = self.stats.connects > 0 - self.stats.connects += 1 - self.connected.set() - self.status("connected", "reconnected" if reconnect else "", reconcile=reconnect and self.private) + self._record_connection_opened_and_emit_status() pinger = watchdog = None + receiver_cancelled = False reason = "closed" self._last_seen, self._stale = now_ms(), "" try: + # Subclass defined behaviour (e.g. send subscription message) after connection is established await self.on_connect() if self.app_ping: pinger = asyncio.get_running_loop().create_task(self._pinger(ws)) if self.idle_timeout: watchdog = asyncio.get_running_loop().create_task(self._watchdog(ws)) + # The inner loop receives successive frames on one connection. + # A receive/setup failure leaves this connection's scope and + # returns control to the outer connection loop after cleanup. while not self._closing: if self.idle_timeout: raw = await asyncio.wait_for(ws.recv(), timeout=self.idle_timeout) else: raw = await ws.recv() - self._dispatch(raw) + self._process_raw_frame_and_emit_events(raw) except asyncio.CancelledError: + receiver_cancelled = True raise except asyncio.TimeoutError: reason = f"no message for {self.idle_timeout}s" @@ -504,13 +619,11 @@ async def _run(self) -> None: for task in (pinger, watchdog): if task: task.cancel() - self._ws = None - self.connected.clear() - self.stats.disconnects += 1 - self.on_disconnect() + self._record_connection_closed_and_emit_status( + reason, notify_consumers=not self._closing and not receiver_cancelled, + ) if self._closing: return - self.status("disconnected", reason) try: await ws.close() except Exception: @@ -544,24 +657,22 @@ async def _pinger(self, ws: Any) -> None: await asyncio.sleep(self.ping_interval) await ws.send(self.app_ping) - def _dispatch(self, raw: Any) -> None: + def _process_raw_frame_and_emit_events(self, raw: Any) -> None: + """Process one received frame, update venue state and enqueue its events. + + A frame can produce zero, one or several events. The adapter's + handle_raw() owns parsing, implementation selection and derived state + such as cached books. This method counts receipt, enqueues the returned + events and reports processing errors as status events for consumers. + """ self.stats.messages += 1 self._last_seen = now_ms() self.stats.last_message_at = now_ms() if isinstance(raw, bytes): raw = raw.decode("utf-8") try: - fast = self.handle_raw(raw) if isinstance(raw, str) else None - if fast is not None: - for event in fast: - self.emit(event) - return - try: - message = json.loads(raw) - except ValueError: - message = raw - for event in self.handle(message): + for event in self.handle_raw(raw): self.emit(event) except Exception as exc: log.debug("synpath.ws %s %s: unreadable message %r", self.venue, self.name, raw, exc_info=True) - self.status("error", f"unreadable message: {type(exc).__name__}: {exc}") + self._record_error_and_emit_status(f"unreadable message: {type(exc).__name__}: {exc}") diff --git a/synpath/ws/betfair.py b/synpath/ws/betfair.py index 8159b26..49e7ee4 100644 --- a/synpath/ws/betfair.py +++ b/synpath/ws/betfair.py @@ -46,7 +46,7 @@ from .. import ids from ..base import Capability from ..errors import AuthenticationError -from .base import BookEvent, BookLevel, Event, LocalBook, MarketStatusEvent, QuoteEvent, Stream, now_ms +from .base import BookEvent, BookLevel, Event, LocalBook, MarketStatusEvent, QuoteEvent, Stream, now_ms, decode_json_or_text log = logging.getLogger("synpath.ws.betfair") @@ -54,21 +54,21 @@ STREAM_HOST = "stream-api.betfair.com" STREAM_PORT = 443 +# Markets one connection may subscribe, by default, before Betfair refuses +# with SUBSCRIPTION_LIMIT_EXCEEDED. MAX_MARKETS = 200 -"""Markets one connection may subscribe, by default, before Betfair refuses -with SUBSCRIPTION_LIMIT_EXCEEDED.""" +# Deepest best-offers ladder Betfair streams. MAX_LADDER_LEVELS = 10 -"""Deepest best-offers ladder Betfair streams.""" +# Seconds Betfair waits for, and this stream allows for, a connection's first message. FIRST_MESSAGE_TIMEOUT = 15.0 -"""Seconds Betfair waits for, and this stream allows for, a connection's first message.""" +# What Betfair conflates a delayed key's stream to: one update per market every three minutes. DELAYED_CONFLATE_MS = 180_000 -"""What Betfair conflates a delayed key's stream to: one update per market every three minutes.""" +# Largest line read: a subscription image of many markets is one line. LINE_LIMIT = 2 ** 24 -"""Largest line read: a subscription image of many markets is one line.""" ONE = Decimal(1) PRICE_PLACES = Decimal("1e-10") @@ -140,10 +140,10 @@ def state_of(market_status: str | None, runner_status: str | None) -> tuple[str, @dataclass class RunnerCache: + # `batb`: what a taker can back at, resting lays. YES asks. back: dict[int, tuple[Decimal, Decimal]] = field(default_factory=dict) - """`batb`: what a taker can back at, resting lays. YES asks.""" + # `batl`: what a taker can lay at, resting backs. YES bids. lay: dict[int, tuple[Decimal, Decimal]] = field(default_factory=dict) - """`batl`: what a taker can lay at, resting backs. YES bids.""" @dataclass @@ -216,23 +216,23 @@ def __init__( self.delayed = delayed self._open_connection = open_connection or asyncio.open_connection self._ssl = ssl_context or ssl.create_default_context() + # Betfair market id -> its cache, kept across reconnects for the resubscribe delta. self.markets: dict[str, MarketCache] = {} - """Betfair market id -> its cache, kept across reconnects for the resubscribe delta.""" + # Runner's Synpath id -> its YES book. self.books: dict[str, LocalBook] = {} - """Runner's Synpath id -> its YES book.""" self.tickers: set[str] = set() + # Betfair market ids whose status changes are passed on; `None` while not watching. self.statuses: set[str] | None = None - """Betfair market ids whose status changes are passed on; `None` while not watching.""" + # Betfair market ids the subscription carries. self.wanted: set[str] = set() - """Betfair market ids the subscription carries.""" + # Betfair markets watched as a whole: every runner's book is kept. self.whole_markets: set[str] = set() - """Betfair markets watched as a whole: every runner's book is kept.""" self.authenticated = False self.connection_id: str | None = None self.initial_clk: str | None = None self.clk: str | None = None + # The market set `clk` belongs to: a resubscribe only resumes that one. self._clk_for: frozenset[str] = frozenset() - """The market set `clk` belongs to: a resubscribe only resumes that one.""" self._next_id = 0 self._auth_id: int | None = None self._sub_id: int | None = None @@ -290,7 +290,7 @@ def _want(self, markets: list[str]) -> list[str]: continue self.wanted.add(market) if refused: - self.status("error", f"more than {MAX_MARKETS} markets on one connection; not subscribed: {', '.join(refused)}") + self._record_error_and_emit_status(f"more than {MAX_MARKETS} markets on one connection; not subscribed: {', '.join(refused)}") return refused async def watch_order_book(self, market_ids: list[str]) -> None: @@ -375,7 +375,11 @@ async def resubscribe(self) -> None: # -- messages ------------------------------------------------------------- - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict): return [] op = message.get("op") @@ -394,19 +398,19 @@ def _on_status(self, message: dict[str, Any]) -> list[Event]: self.authenticated = True self._later(self.resubscribe()) elif message.get("id") == self._sub_id: - self.status("subscribed", f"{len(self.wanted)} markets") + self._emit_status_event("subscribed", f"{len(self.wanted)} markets") return [] code = str(message.get("errorCode") or "") detail = f"{code}: {message.get('errorMessage') or ''}".strip(": ") if code in APP_KEY_ERRORS: - self.status("failed", f"Betfair refused the application key for streaming ({detail})") + self._emit_status_event("failed", f"Betfair refused the application key for streaming ({detail})") self._later(self.close()) elif code in SESSION_ERRORS: # The next connection logs in again. self.session.invalidate() - self.status("error", f"session refused ({detail}); logging in again") + self._record_error_and_emit_status(f"session refused ({detail}); logging in again") else: - self.status("error", f"Betfair refused: {detail}") + self._record_error_and_emit_status(f"Betfair refused: {detail}") return [] def _on_market_changes(self, message: dict[str, Any]) -> list[Event]: @@ -488,7 +492,7 @@ def _book_events(self, market: str, key: tuple[int, float], runner: RunnerCache, best_bid=book.best_bid, best_ask=book.best_ask, info={"delayed": self.delayed, "clk": self.clk}, )) if recovering: - self.status("resynced", f"book {synpath_id}", key=synpath_id) + self._record_resync_and_emit_status(f"book {synpath_id}", key=synpath_id) if synpath_id in self.tickers: best_bid = max(bids, default=None, key=lambda level: level[0]) best_ask = min(asks, default=None, key=lambda level: level[0]) diff --git a/synpath/ws/grpc.py b/synpath/ws/grpc.py index 9a190d7..f1a5b40 100644 --- a/synpath/ws/grpc.py +++ b/synpath/ws/grpc.py @@ -31,16 +31,16 @@ from pathlib import Path from typing import Any, AsyncIterator, Awaitable, Callable, Literal -from .base import _CLOSED, Stream, log, now_ms +from .base import _CLOSED, Event, Stream, log, now_ms PROTO_ENV = "SYNPATH_POLYMARKET_US_PROTOS" +# `(method, request, metadata) -> responses`: how a stream opens one call. +# The default speaks gRPC; tests pass scripted responses. CallOpener = Callable[[str, dict[str, Any], list[tuple[str, str]]], Awaitable[AsyncIterator[Any]]] -"""`(method, request, metadata) -> responses`: how a stream opens one call. -The default speaks gRPC; tests pass scripted responses.""" +# Yielded by a call once the server has answered (its headers arrived). READY = object() -"""Yielded by a call once the server has answered (its headers arrived).""" Outcome = Literal["retry", "reauthenticate", "slow_down", "fatal"] @@ -297,8 +297,8 @@ def __init__( **kwargs: Any, ): super().__init__(target, **kwargs) + # Plaintext, for a local test server only. self.insecure = insecure - """Plaintext, for a local test server only.""" self.protos = protos self._call_opener = call self.keepalive_s = keepalive_s @@ -310,6 +310,10 @@ def __init__( # -- venue hooks ---------------------------------------------------------- + def handle(self, message: dict[str, Any]) -> list[Event]: + """Interpret one decoded RPC message; separate from WebSocket frames.""" + return [] + def request(self) -> dict[str, Any] | None: """The request for the next call; `None` waits for `resubscribe()`.""" return {} @@ -429,17 +433,17 @@ async def _consume(self, responses: AsyncIterator[Any], on_ready: Callable[[], N events = self.handle(message) except Exception as exc: log.debug("synpath.ws %s %s: unreadable message %r", self.venue, self.name, item, exc_info=True) - self.status("error", f"unreadable message: {type(exc).__name__}: {exc}") + self._record_error_and_emit_status(f"unreadable message: {type(exc).__name__}: {exc}") continue for event in events: self.emit(event) def _fail(self, detail: str) -> None: self._closing = True - self.status("failed", detail) + self._emit_status_event("failed", detail) self._queue.put_nowait(_CLOSED) - async def _run(self) -> None: + async def _run_connection_and_receive_loop(self) -> None: attempt = 0 while not self._closing: self._resubscribe.clear() @@ -450,7 +454,7 @@ async def _run(self) -> None: except asyncio.CancelledError: raise except Exception as exc: - self.status("connect_failed", f"{type(exc).__name__}: {exc}") + self._emit_status_event("connect_failed", f"{type(exc).__name__}: {exc}") await self._sleep(self.backoff(attempt)) attempt += 1 continue @@ -467,7 +471,7 @@ async def _run(self) -> None: except Exception as exc: code = status_code(exc) self.last_status = code - self.status("connect_failed", f"{code or type(exc).__name__}: {status_details(exc) if code else exc}") + self._emit_status_event("connect_failed", f"{code or type(exc).__name__}: {status_details(exc) if code else exc}") if await self._after_failure(code, f"{code}: {status_details(exc)}", attempt): return attempt += 1 @@ -481,10 +485,9 @@ def ready() -> None: return confirmed = True attempt = 0 - reconnect = self.stats.connects > 0 - self.stats.connects += 1 - self.connected.set() - self.status("connected", "reconnected" if reconnect else "", reconcile=reconnect and self.reconcile_on_reconnect()) + self._record_connection_opened_and_emit_status( + reconcile_on_reconnect=self.reconcile_on_reconnect(), + ) self._last_seen, self._stale = now_ms(), "" loop = asyncio.get_running_loop() @@ -495,6 +498,7 @@ def ready() -> None: code: str | None = None reason = "closed" resubscribing = False + receiver_cancelled = False try: done, _ = await asyncio.wait(watched, return_when=asyncio.FIRST_COMPLETED) if reader in done: @@ -506,6 +510,9 @@ def ready() -> None: else: resubscribing = True reason = "resubscribing" + except asyncio.CancelledError: + receiver_cancelled = True + raise finally: for task in (reader, waker, watching): if task is not None and not task.done(): @@ -522,18 +529,22 @@ def ready() -> None: pass self._grpc_call = None if confirmed: - self.connected.clear() - self.stats.disconnects += 1 - self.on_disconnect() + # Preserve the last status visible when publishing a normal + # disconnect; shutdown/cancellation previously left it alone. + if not self._closing and not receiver_cancelled: + self.last_status = code + self._record_connection_closed_and_emit_status( + reason, notify_consumers=not self._closing and not receiver_cancelled, + ) + else: + # A call refused before its first response was never an + # established connection, but may still hold venue state. + self.on_disconnect() if self._closing: return self.last_status = code - # A call the venue refused before answering was never a connection, - # and one replaced before it answered was neither. - if confirmed: - self.status("disconnected", reason) - elif not resubscribing: - self.status("connect_failed", reason) + if not confirmed and not resubscribing: + self._emit_status_event("connect_failed", reason) if resubscribing: continue if await self._after_failure(code, reason, attempt): diff --git a/synpath/ws/hyperliquid.py b/synpath/ws/hyperliquid.py index c754480..9b517fc 100644 --- a/synpath/ws/hyperliquid.py +++ b/synpath/ws/hyperliquid.py @@ -37,6 +37,7 @@ from ..trading.hyperliquid import fill_of, order_of, outcome_coin from ..trading.types import Account, Side from .base import ( + decode_json_or_text, BookEvent, BookLevel, Event, FillEvent, LocalBook, OrderEvent, QuoteEvent, Stream, TradeEvent, VenueEvent, maybe_D, now_ms, ) @@ -73,9 +74,9 @@ class _HyperliquidStream(Stream): def __init__(self, *, testnet: bool = False, url: str | None = None, ping_interval: float = 30.0, **kwargs: Any): super().__init__(url or (TESTNET_WS_URL if testnet else WS_URL), ping_interval=ping_interval, **kwargs) + # Every subscription asked for, keyed for de-duplication, sent again + # on every connect. self.subscriptions: dict[str, dict[str, Any]] = {} - """Every subscription asked for, keyed for de-duplication, sent again - on every connect.""" async def _subscribe(self, subscription: dict[str, Any]) -> None: key = repr(sorted(subscription.items())) @@ -98,10 +99,10 @@ def _common(self, channel: Any, message: dict[str, Any]) -> list[Event] | None: if channel in ("pong", "subscriptionResponse"): if channel == "subscriptionResponse": sub = (message.get("data") or {}).get("subscription") or {} - self.status("subscribed", str(sub.get("type") or ""), key=str(sub.get("coin") or sub.get("user") or "")) + self._emit_status_event("subscribed", str(sub.get("type") or ""), key=str(sub.get("coin") or sub.get("user") or "")) return [] if channel == "error": - self.status("error", f"venue: {message.get('data')}") + self._record_error_and_emit_status(f"venue: {message.get('data')}") return [] return None @@ -129,13 +130,13 @@ class HyperliquidMarketStream(_HyperliquidStream): def __init__(self, **kwargs: Any): super().__init__(**kwargs) + # YES coin -> its book. self.books: dict[str, LocalBook] = {} - """YES coin -> its book.""" + # YES coin -> Synpath market id. self.markets: dict[str, str] = {} - """YES coin -> Synpath market id.""" + # YES coin -> when its prints were last subscribed, in ms: the + # replayed prints before it are dropped. self.trades_since: dict[str, int] = {} - """YES coin -> when its prints were last subscribed, in ms: the - replayed prints before it are dropped.""" def _track(self, market_id: str) -> str: coin = yes_coin(market_id) @@ -191,7 +192,11 @@ def on_disconnect(self) -> None: # -- reading -------------------------------------------------------------- - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict): return [] channel = message.get("channel") @@ -219,7 +224,7 @@ def _on_book(self, data: dict[str, Any]) -> list[Event]: book.replace(levels_of(rows[0] if rows else []), levels_of(rows[1] if len(rows) > 1 else [])) book.timestamp = int(data["time"]) if data.get("time") else None if recovering: - self.status("resynced", f"book {self.markets.get(coin, coin)}", key=self.markets.get(coin)) + self._record_resync_and_emit_status(f"book {self.markets.get(coin, coin)}", key=self.markets.get(coin)) bids, asks = book.levels() return [BookEvent( venue=VENUE, market_id=self.markets.get(coin, coin), side="yes", kind="snapshot", @@ -286,8 +291,8 @@ def __init__(self, *, address: str | None = None, account_name: str = "default", f"or set HYPERLIQUID_ACCOUNT_ADDRESS") self.address = found.lower() self.account = Account(venue=VENUE, name=account_name) + # Markets to report, or `None` for every outcome. self.only: set[str] | None = None - """Markets to report, or `None` for every outcome.""" async def watch_orders(self, market_ids: list[str] | None = None) -> None: """This address's orders, on every outcome or only these markets.""" @@ -309,7 +314,11 @@ def _narrow(self, market_ids: list[str] | None) -> None: def _wanted(self, market_id: str) -> bool: return self.only is None or market_id in self.only - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict): return [] channel = message.get("channel") @@ -332,7 +341,7 @@ def _on_fills(self, data: dict[str, Any]) -> list[Event]: rows = data.get("fills") or [] if data.get("isSnapshot"): outcome_rows = sum(1 for row in rows if outcome_coin(str(row.get("coin") or ""))) - self.status("subscribed", f"userFills: snapshot of {outcome_rows} recent outcome fills not replayed", + self._emit_status_event("subscribed", f"userFills: snapshot of {outcome_rows} recent outcome fills not replayed", key="userFills") return [] events: list[Event] = [] diff --git a/synpath/ws/kalshi.py b/synpath/ws/kalshi.py index e00b3d2..fb6d0b4 100644 --- a/synpath/ws/kalshi.py +++ b/synpath/ws/kalshi.py @@ -41,6 +41,7 @@ from ..trading.kalshi import KalshiSigner, fill_of, order_of, position_of from ..trading.types import Account, Side from .base import ( + decode_json_or_text, ONE, BookEvent, BookLevel, D, Event, LocalBook, MarketStatusEvent, OrderEvent, FillEvent, PositionEvent, QuoteEvent, Stream, TradeEvent, VenueEvent, maybe_D, ) @@ -53,11 +54,11 @@ } PRIVATE_CHANNELS = frozenset({"fill", "user_orders", "market_positions", "order_group_updates"}) +# Channels that take a market filter; the rest take none. MARKET_SCOPED = frozenset({"orderbook_delta", "ticker", "trade", "fill", "user_orders", "market_positions"}) -"""Channels that take a market filter; the rest take none.""" +# Ask again for a snapshot that has not arrived after this long. RESYNC_AFTER_S = 10.0 -"""Ask again for a snapshot that has not arrived after this long.""" LIFECYCLE_STATE = { "created": "created", @@ -76,8 +77,8 @@ class Channel: server calls it on this connection.""" name: str + # `None` means every market. tickers: set[str] | None - """`None` means every market.""" sid: int | None = None command_id: int | None = None last_seq: int | None = None @@ -227,11 +228,19 @@ def book(self, market_id: str, side: str = "yes") -> LocalBook | None: # -- reading -------------------------------------------------------------- - def handle_raw(self, raw: str) -> list[Event] | None: + def handle_raw(self, raw: str) -> list[Event]: + """Process a complete frame; this adapter owns native/Python selection.""" + events = self._try_process_frame_with_rust(raw) + # An empty list means handled with no events; only None requests fallback. + if events is not None: + return events + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _try_process_frame_with_rust(self, raw: str) -> list[Event] | None: """Book snapshots and deltas, decoded and applied by the Rust core on these same books. The sequence check runs here in between, as in - `handle`. Everything else, and everything when the core is not built, - goes to `handle`.""" + `_handle_decoded_message`. Everything else, and everything when the core is not built, + goes to `_handle_decoded_message`.""" if _native.core is None: return None decoded = _native.core.kalshi_book_message(raw, self.books) @@ -249,7 +258,7 @@ def handle_raw(self, raw: str) -> list[Event] | None: _, ticker, recovering, bids, asks, best_bid, best_ask, info = done self._snapshot_requested.pop(ticker, None) if recovering: - self.status("resynced", f"orderbook {ticker}", key=ticker) + self._record_resync_and_emit_status(f"orderbook {ticker}", key=ticker) return [BookEvent( venue=VENUE, market_id=ids.qualify(VENUE, ticker), kind="snapshot", bids=bids, asks=asks, best_bid=best_bid, best_ask=best_ask, sequence=seq, info=info, @@ -260,7 +269,7 @@ def handle_raw(self, raw: str) -> list[Event] | None: best_bid=best_bid, best_ask=best_ask, sequence=seq, timestamp=stamp, info=info, )] - def handle(self, message: Any) -> list[Event]: + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict): return [] kind = message.get("type") @@ -310,7 +319,7 @@ def _on_subscribed(self, message: dict[str, Any]) -> list[Event]: return [] channel.sid = int(body["sid"]) self._by_sid[channel.sid] = channel - self.status("subscribed", channel.name, key=channel.name) + self._emit_status_event("subscribed", channel.name, key=channel.name) if channel.pending_add: added, channel.pending_add = sorted(channel.pending_add), set() self._later(self._send_command("update_subscription", { @@ -325,10 +334,10 @@ def _on_error(self, message: dict[str, Any]) -> list[Event]: channel = self._by_command.pop(message.get("id"), None) or self._by_sid.get(message.get("sid")) if code == 25 and channel is not None: # The server dropped messages for this subscription: start it again. - self.status("gap", f"{channel.name}: {detail}", key=channel.name, reconcile=channel.name in PRIVATE_CHANNELS) + self._record_gap_and_emit_status(f"{channel.name}: {detail}", key=channel.name, reconcile=channel.name in PRIVATE_CHANNELS) self._restart(channel) return [] - self.status("error", detail, key=channel.name if channel else None) + self._record_error_and_emit_status(detail, key=channel.name if channel else None) return [] def _check_sequence(self, channel: Channel, seq: int, kind: str | None) -> None: @@ -336,8 +345,7 @@ def _check_sequence(self, channel: Channel, seq: int, kind: str | None) -> None: channel.last_seq = seq if last is None or seq == last + 1: return - self.status( - "gap", f"{channel.name}: expected seq {last + 1}, got {seq}", key=channel.name, + self._record_gap_and_emit_status(f"{channel.name}: expected seq {last + 1}, got {seq}", key=channel.name, reconcile=channel.name in PRIVATE_CHANNELS, ) if channel.name == "orderbook_delta": @@ -383,7 +391,7 @@ def _on_snapshot(self, message: dict[str, Any], body: dict[str, Any], channel: C book.sequence = message.get("seq") self._snapshot_requested.pop(ticker, None) if recovering: - self.status("resynced", f"orderbook {ticker}", key=ticker) + self._record_resync_and_emit_status(f"orderbook {ticker}", key=ticker) bids, asks = book.levels() return [BookEvent( venue=VENUE, market_id=ids.qualify(VENUE, ticker), kind="snapshot", bids=bids, asks=asks, diff --git a/synpath/ws/limitless.py b/synpath/ws/limitless.py index 6a5ce10..3861c34 100644 --- a/synpath/ws/limitless.py +++ b/synpath/ws/limitless.py @@ -83,7 +83,7 @@ def __init__(self, *, url: str = WS_URL, **kwargs: Any): # -- Socket.IO framing -------------------------------------------------- - def handle_raw(self, raw: str) -> list[Event] | None: + def handle_raw(self, raw: str) -> list[Event]: if raw.startswith("0"): self._later(self.send(f"40{NAMESPACE},")) return [] @@ -95,12 +95,12 @@ def handle_raw(self, raw: str) -> list[Event] | None: prefix = f"4{{}}{NAMESPACE}" if raw.startswith(prefix.format("0")): self.joined = True - self.status("subscribed", NAMESPACE) + self._emit_status_event("subscribed", NAMESPACE) self._later(self.subscribe()) return [] if raw.startswith(prefix.format("4")): detail = raw[len(prefix.format("4")) + 1:] - self.status("error", f"venue refused the connection: {detail}") + self._record_error_and_emit_status(f"venue refused the connection: {detail}") return [] if raw.startswith(prefix.format("1")): self.joined = False @@ -129,7 +129,7 @@ async def subscribe(self) -> None: def on_event(self, name: str, data: Any) -> list[Event]: if name in ("exception", "error"): detail = data.get("message") if isinstance(data, dict) else data - self.status("error", f"venue error: {detail}") + self._record_error_and_emit_status(f"venue error: {detail}") return [] def _later(self, coroutine: Any) -> None: @@ -163,8 +163,8 @@ class LimitlessMarketStream(_LimitlessStream): def __init__(self, **kwargs: Any): super().__init__(**kwargs) + # Slug -> its YES book. self.books: dict[str, LocalBook] = {} - """Slug -> its YES book.""" self.tickers: set[str] = set() self.statuses: set[str] = set() self._versions: dict[str, int] = {} @@ -246,7 +246,7 @@ def _on_book(self, data: dict[str, Any]) -> list[Event]: book.replace(bids, asks) book.timestamp = stamp if recovering: - self.status("resynced", f"book {market_id}", key=market_id) + self._record_resync_and_emit_status(f"book {market_id}", key=market_id) levels_bids, levels_asks = book.levels() events.append(BookEvent( venue=VENUE, market_id=market_id, side="yes", kind="snapshot", bids=levels_bids, asks=levels_asks, @@ -287,8 +287,8 @@ def __init__(self, *, token_id: str, secret: str, catalog: Any = None, account_n self.catalog = catalog self.account = Account(venue=VENUE, name=account_name) self.watching = False + # Token id -> (market id, "yes" or "no"). self.tokens: dict[str, tuple[str, str]] = {} - """Token id -> (market id, "yes" or "no").""" self._indexed = False self._waiting: list[dict[str, Any]] = [] @@ -361,12 +361,12 @@ async def _resolve_waiting(self) -> None: self._indexed = True self.tokens.update(await asyncio.to_thread(self._catalog().token_index)) except Exception as exc: # pragma: no cover - the catalog being down - self.status("error", f"could not place order events: {type(exc).__name__}: {exc}") + self._record_error_and_emit_status(f"could not place order events: {type(exc).__name__}: {exc}") waiting, self._waiting = self._waiting, [] for data in waiting: placed = self._place(data) if placed is None: - self.status("error", f"order event for an unknown token: {data.get('orderId')}") + self._record_error_and_emit_status(f"order event for an unknown token: {data.get('orderId')}") continue for event in self._events(data, *placed): self.emit(event) diff --git a/synpath/ws/opinion.py b/synpath/ws/opinion.py index a987f4b..8fa629b 100644 --- a/synpath/ws/opinion.py +++ b/synpath/ws/opinion.py @@ -41,6 +41,7 @@ from ..trading.opinion import fill_of, order_of, outcome_of from ..trading.types import Account, Side from .base import ( + decode_json_or_text, ONE, BookEvent, BookLevel, Event, FillEvent, LocalBook, OrderEvent, QuoteEvent, Stream, TradeEvent, VenueEvent, ) @@ -48,9 +49,9 @@ WS_URL = "wss://ws.opinion.trade" HEARTBEAT = '{"action":"HEARTBEAT"}' +# Seconds between re-reads of every watched book. Each costs two requests +# per market against the venue's 5-a-second public REST limit. RESYNC_INTERVAL = 60.0 -"""Seconds between re-reads of every watched book. Each costs two requests -per market against the venue's 5-a-second public REST limit.""" def api_key_from(api_key: str | None) -> str: @@ -79,8 +80,8 @@ class Watched: native: str yes_token: str no_token: str + # The categorical topic's id for an option; `None` for a binary topic. topic: str | None - """The categorical topic's id for an option; `None` for a binary topic.""" def side_of(self, token: str) -> str: return "no" if token == self.no_token else "yes" @@ -103,19 +104,19 @@ def __init__( from ..opinion import Opinion catalog = Opinion() + # The read adapter: which tokens and topic a market has, and books. self.catalog = catalog - """The read adapter: which tokens and topic a market has, and books.""" self.watched: dict[str, Watched] = {} + # `(channel, "marketId" | "rootMarketId", id)`, sent again on every connect. self.subscriptions: set[tuple[str, str, int]] = set() - """`(channel, "marketId" | "rootMarketId", id)`, sent again on every connect.""" - def status(self, state: Any, detail: str = "", **kwargs: Any) -> None: + def _emit_status_event(self, state: Any, detail: str = "", **kwargs: Any) -> None: # The key travels in the URL, so it is kept out of anything reported. - super().status(state, detail.replace(self._key, "***") if detail else detail, **kwargs) + super()._emit_status_event(state, detail.replace(self._key, "***") if detail else detail, **kwargs) async def _open(self) -> Any: - if self._connect is not None: - return await self._connect(self.url, additional_headers=self.headers()) + if self._websocket_connection_opener is not None: + return await self._websocket_connection_opener(self.url, additional_headers=self.headers()) import websockets # Protocol pings as well as the venue's heartbeat frame: the heartbeat @@ -158,7 +159,7 @@ def _venue_error(self, message: dict[str, Any]) -> list[Event]: refusing something. Only the refusals are reported.""" code = message.get("code", message.get("errno", 0)) if message.get("error") or (isinstance(code, int) and code not in (0, 200)): - self.status("error", f"venue: {message}") + self._record_error_and_emit_status(f"venue: {message}") return [] @@ -177,14 +178,14 @@ class OpinionMarketStream(_OpinionStream): def __init__(self, *, resync_interval: float | None = RESYNC_INTERVAL, **kwargs: Any): super().__init__(**kwargs) self.resync_interval = resync_interval + # Token id -> its book. Both sides are read, not mirrored. self.books: dict[str, LocalBook] = {} - """Token id -> its book. Both sides are read, not mirrored.""" self.tokens: dict[str, Watched] = {} self.depth: set[str] = set() self.trades: set[str] = set() self.prices: set[str] = set() + # Token -> changes that arrived while its snapshot was being read. self._pending: dict[str, list[dict[str, Any]]] = {} - """Token -> changes that arrived while its snapshot was being read.""" self._resyncer: asyncio.Task | None = None async def watch_order_book(self, market_ids: list[str]) -> None: @@ -290,7 +291,7 @@ async def _snapshot(self, watched: Watched, *, check: bool = False) -> None: except Exception as exc: for token in tokens: self._pending.pop(token, None) - self.status("error", f"book {watched.market_id}: snapshot failed: {type(exc).__name__}: {exc}", + self._record_error_and_emit_status(f"book {watched.market_id}: snapshot failed: {type(exc).__name__}: {exc}", key=watched.market_id) return for token, side, snapshot in zip(tokens, ("yes", "no"), raws): @@ -303,8 +304,7 @@ async def _snapshot(self, watched: Watched, *, check: bool = False) -> None: recovering = not book.ready and book.timestamp is not None if check and book.ready and not pending: if dict(bids) != book.bids or dict(asks) != book.asks: - self.status( - "gap", f"book {watched.market_id} {side}: local top {book.best_bid}/{book.best_ask} " + self._record_gap_and_emit_status(f"book {watched.market_id} {side}: local top {book.best_bid}/{book.best_ask} " f"differs from the venue's", key=watched.market_id, ) recovering = True @@ -315,7 +315,7 @@ async def _snapshot(self, watched: Watched, *, check: bool = False) -> None: for change in pending: self._apply(book, change) if recovering: - self.status("resynced", f"book {watched.market_id} {side}", key=watched.market_id) + self._record_resync_and_emit_status(f"book {watched.market_id} {side}", key=watched.market_id) levels_bids, levels_asks = book.levels() self.emit(BookEvent( venue=VENUE, market_id=watched.market_id, side=side, kind="snapshot", # type: ignore[arg-type] @@ -340,11 +340,19 @@ def _apply(book: LocalBook, change: dict[str, Any]) -> tuple[str, Decimal, Decim # -- reading -------------------------------------------------------------- - def handle_raw(self, raw: str) -> list[Event] | None: + def handle_raw(self, raw: str) -> list[Event]: + """Process a complete frame; this adapter owns native/Python selection.""" + events = self._try_process_frame_with_rust(raw) + # An empty list means handled with no events; only None requests fallback. + if events is not None: + return events + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _try_process_frame_with_rust(self, raw: str) -> list[Event] | None: """A depth change, decoded and applied by the Rust core on the same book. A change held back while a snapshot is fetched stays a dict for the replay, so that, everything else, and everything when the - core is not built, goes to `handle`.""" + core is not built, goes to `_handle_decoded_message`.""" if _native.core is None: return None change = _native.core.opinion_depth(raw) @@ -367,11 +375,11 @@ def handle_raw(self, raw: str) -> list[Event] | None: bids=bids, asks=asks, best_bid=best_bid, best_ask=best_ask, )] - def handle(self, message: Any) -> list[Event]: + def _handle_decoded_message(self, message: Any) -> list[Event]: if isinstance(message, list): events: list[Event] = [] for item in message: - events.extend(self.handle(item)) + events.extend(self._handle_decoded_message(item)) return events if not isinstance(message, dict): return [] @@ -464,11 +472,15 @@ async def watch_orders(self, market_ids: list[str]) -> None: watch_my_trades = watch_orders - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if isinstance(message, list): events: list[Event] = [] for item in message: - events.extend(self.handle(item)) + events.extend(self._handle_decoded_message(item)) return events if not isinstance(message, dict): return [] diff --git a/synpath/ws/polymarket.py b/synpath/ws/polymarket.py index 88cf12b..7148355 100644 --- a/synpath/ws/polymarket.py +++ b/synpath/ws/polymarket.py @@ -30,6 +30,7 @@ from ..trading.polymarket import GAMMA_URL, MarketTokens, fills_of, order_of from ..trading.types import Account, OrderStatus, Side from .base import ( + decode_json_or_text, ONE, BookEvent, BookLevel, D, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, QuoteEvent, Stream, TradeEvent, VenueEvent, maybe_D, @@ -60,12 +61,12 @@ def __init__(self, *, url: str = MARKET_URL, gamma_url: str = GAMMA_URL, **kwarg super().__init__(url, **kwargs) self.tokens: set[str] = set() self.books: dict[str, LocalBook] = {} + # Token id -> condition id, learned from the stream. self.markets: dict[str, str] = {} - """Token id -> condition id, learned from the stream.""" + # Which token is which side of which market, from the Gamma catalog. self.catalog = TokenCatalog(gamma_url) - """Which token is which side of which market, from the Gamma catalog.""" + # Token -> (timestamp, venue best bid, venue best ask) not yet checked. self._unverified: dict[str, tuple[int | None, Any, Any]] = {} - """Token -> (timestamp, venue best bid, venue best ask) not yet checked.""" async def watch_order_book(self, market_ids: list[str]) -> None: """Books, trades, top of book and lifecycle for these markets, both @@ -128,11 +129,19 @@ def _where(self, token: str, condition: str) -> tuple[str, str]: # -- reading -------------------------------------------------------------- - def handle_raw(self, raw: str) -> list[Event] | None: + def handle_raw(self, raw: str) -> list[Event]: + """Process a complete frame; this adapter owns native/Python selection.""" + events = self._try_process_frame_with_rust(raw) + # An empty list means handled with no events; only None requests fallback. + if events is not None: + return events + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _try_process_frame_with_rust(self, raw: str) -> list[Event] | None: """Book snapshots and price changes, parsed and applied by the Rust core on these same books, with the same top-of-book checks. Frames holding anything else, and everything when the core is not built, - go to `handle`.""" + go to `_handle_decoded_message`.""" if _native.core is None: return None done = _native.core.polymarket_market(raw, self.books, self._unverified, self.markets) @@ -149,17 +158,17 @@ def handle_raw(self, raw: str) -> list[Event] | None: )) elif item[0] == "gap": _, token, local_bid, local_ask, venue_bid, venue_ask = item - self.status("gap", f"book {token}: local top {local_bid}/{local_ask}, venue {venue_bid}/{venue_ask}", key=token) + self._record_gap_and_emit_status(f"book {token}: local top {local_bid}/{local_ask}, venue {venue_bid}/{venue_ask}", key=token) self._later(self._resubscribe(token)) elif item[0] == "resynced": - self.status("resynced", f"book {item[1]}", key=item[1]) + self._record_resync_and_emit_status(f"book {item[1]}", key=item[1]) return events - def handle(self, message: Any) -> list[Event]: + def _handle_decoded_message(self, message: Any) -> list[Event]: if isinstance(message, list): events: list[Event] = [] for item in message: - events.extend(self.handle(item)) + events.extend(self._handle_decoded_message(item)) return events if not isinstance(message, dict): return [] # PONG @@ -217,7 +226,7 @@ def _on_book(self, message: dict[str, Any]) -> list[Event]: book.timestamp = _ms(message.get("timestamp")) self._unverified.pop(token, None) if recovering: - self.status("resynced", f"book {token}", key=token) + self._record_resync_and_emit_status(f"book {token}", key=token) bids, asks = book.levels() market_id, side = self._where(token, market) return [BookEvent( @@ -276,8 +285,7 @@ def _verify(self, token: str) -> bool: _, best_bid, best_ask = pending if _agrees(book.best_bid, best_bid) and _agrees(book.best_ask, best_ask): return True - self.status( - "gap", f"book {token}: local top {book.best_bid}/{book.best_ask}, venue {best_bid}/{best_ask}", key=token, + self._record_gap_and_emit_status(f"book {token}: local top {book.best_bid}/{book.best_ask}, venue {best_bid}/{best_ask}", key=token, ) book.invalidate() self._later(self._resubscribe(token)) @@ -357,11 +365,15 @@ async def on_connect(self) -> None: frame["markets"] = sorted(self.markets) await self.send(frame) - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if isinstance(message, list): events: list[Event] = [] for item in message: - events.extend(self.handle(item)) + events.extend(self._handle_decoded_message(item)) return events if not isinstance(message, dict): return [] diff --git a/synpath/ws/polymarket_us.py b/synpath/ws/polymarket_us.py index ea9b829..98bef8a 100644 --- a/synpath/ws/polymarket_us.py +++ b/synpath/ws/polymarket_us.py @@ -35,6 +35,7 @@ ) from ..trading.types import Account, Fill, Liquidity, Side from .base import ( + decode_json_or_text, BookEvent, D, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, Stream, TradeEvent, BalanceEvent, maybe_D, ) @@ -96,7 +97,7 @@ def _common(self, message: dict[str, Any]) -> list[Event] | None: if "heartbeat" in message: return [] if "error" in message: - self.status("error", str(message.get("error")), key=message.get("requestId") or message.get("request_id")) + self._record_error_and_emit_status(str(message.get("error")), key=message.get("requestId") or message.get("request_id")) return [] return None @@ -122,8 +123,8 @@ def __init__(self, credentials: PolymarketUSCredentials, **kwargs: Any): self.states: dict[str, str] = {} async def watch_order_book(self, market_ids: list[str], *, debounced: bool = False) -> None: + # Full visible book and market state for these markets. slugs = _slugs(market_ids) - """Full visible book and market state for these markets.""" for slug in slugs: self.books.setdefault(slug, LocalBook()) await self._subscribe("SUBSCRIPTION_TYPE_MARKET_DATA", slugs) @@ -145,10 +146,18 @@ def book(self, market_id: str, side: str = "yes") -> LocalBook | None: book = self.books.get(ids.native(VENUE, market_id)) return None if book is None else book if side != "no" else book.mirrored() - def handle_raw(self, raw: str) -> list[Event] | None: + def handle_raw(self, raw: str) -> list[Event]: + """Process a complete frame; this adapter owns native/Python selection.""" + events = self._try_process_frame_with_rust(raw) + # An empty list means handled with no events; only None requests fallback. + if events is not None: + return events + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _try_process_frame_with_rust(self, raw: str) -> list[Event] | None: """Full market data, parsed and applied to the book by the Rust core. Everything else, and everything when the core is not built, goes to - `handle`.""" + `_handle_decoded_message`.""" if _native.core is None: return None done = _native.core.polymarket_us_market_data(raw, self.books) @@ -159,7 +168,7 @@ def handle_raw(self, raw: str) -> list[Event] | None: self.books[slug].timestamp = stamp return self._market_events(slug, bids, asks, best_bid, best_ask, stamp, native, stats) - def handle(self, message: Any) -> list[Event]: + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict): return [] common = self._common(message) @@ -233,9 +242,9 @@ class PolymarketUSPrivateStream(_SignedStream): } async def watch_orders(self, market_ids: list[str] | None = None) -> None: + # Open orders as a snapshot, then every execution: acceptance, fills, + # cancels, replaces, rejects. Fills arrive as `FillEvent`s too. slugs = _slugs(market_ids) - """Open orders as a snapshot, then every execution: acceptance, fills, - cancels, replaces, rejects. Fills arrive as `FillEvent`s too.""" await self._subscribe("SUBSCRIPTION_TYPE_ORDER", slugs) watch_my_trades = watch_orders @@ -246,7 +255,11 @@ async def watch_positions(self, market_ids: list[str] | None = None) -> None: async def watch_balance(self) -> None: await self._subscribe("SUBSCRIPTION_TYPE_ACCOUNT_BALANCE", None) - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict): return [] common = self._common(message) diff --git a/synpath/ws/polymarket_us_exchange.py b/synpath/ws/polymarket_us_exchange.py index b9402cf..51c2356 100644 --- a/synpath/ws/polymarket_us_exchange.py +++ b/synpath/ws/polymarket_us_exchange.py @@ -98,13 +98,13 @@ "INSTRUMENT_STATE_TERMINATED": "settled", } +# Every other state (new, in flight, pending, acknowledged, retrying, and any +# state added later) is a trade still on its way: `matched`. TRADE_SETTLEMENT = { "TRADE_STATE_CLEARED": SettlementState.CONFIRMED, "TRADE_STATE_BUSTED": SettlementState.FAILED, "TRADE_STATE_REJECTED": SettlementState.FAILED, } -"""Every other state (new, in flight, pending, acknowledged, retrying, and any -state added later) is a trade still on its way: `matched`.""" def load_protos(path: Any = None, **kwargs: Any) -> ProtoBundle: @@ -232,8 +232,8 @@ def side(rows: Any) -> list[tuple[Decimal, Decimal]]: class _ExchangeStream(GrpcStream): venue = VENUE + # Sends `x-participant-id`; reference and market data need none. account_scoped = True - """Sends `x-participant-id`; reference and market data need none.""" def __init__( self, @@ -288,7 +288,7 @@ async def prepare(self, message: dict[str, Any]) -> None: try: await self.trading.load_instruments(missing, strict=False) except ExchangeError as exc: - self.status("error", f"reference data for {missing[:3]} failed: {exc}") + self._record_error_and_emit_status(f"reference data for {missing[:3]} failed: {exc}") def _each(self, items: Any, read: Callable[[dict[str, Any]], list[Event]]) -> list[Event]: """Read items one at a time, so one unreadable item costs only itself.""" @@ -297,7 +297,7 @@ def _each(self, items: Any, read: Callable[[dict[str, Any]], list[Event]]) -> li try: events.extend(read(item)) except Exception as exc: - self.status("error", f"unreadable item: {type(exc).__name__}: {exc}") + self._record_error_and_emit_status(f"unreadable item: {type(exc).__name__}: {exc}") return events @@ -322,10 +322,10 @@ def __init__( self.firms = list(firms or []) if isinstance(resume_token, bytes): resume_token = base64.b64encode(resume_token).decode() + # The last token, base64 as the venue's JSON mapping writes bytes. self.resume_token: str | None = resume_token or None - """The last token, base64 as the venue's JSON mapping writes bytes.""" + # Where to resume from without a token, in milliseconds. self.resume_time: int | None = resume_time - """Where to resume from without a token, in milliseconds.""" self.last_event_time: int | None = None request_fields: tuple[str, ...] = ("symbols", "firms") @@ -352,7 +352,7 @@ def classify(self, code: str | None) -> Outcome: since = self.last_event_time if self.last_event_time is not None else self.resume_time self.resume_token = None self.resume_time = since - self.status("error", f"the venue refused the resume token ({code}); resuming from {rfc3339(since) if since else 'now'}") + self._record_error_and_emit_status(f"the venue refused the resume token ({code}); resuming from {rfc3339(since) if since else 'now'}") return "retry" return super().classify(code) @@ -499,8 +499,8 @@ def __init__(self, source: Any, *, symbols: list[str] | None = None, accounts: l self.session_id: str | None = None async def watch_orders(self, market_ids: list[str] | None = None, accounts: list[str] | None = None) -> None: + # Narrow (or widen, with `None`) what the stream carries; empty means everything. symbols = None if market_ids is None else [ids.native(VENUE, m) for m in market_ids] - """Narrow (or widen, with `None`) what the stream carries; empty means everything.""" symbols, accounts = list(symbols or []), list(accounts or []) if (symbols, accounts) != (self.symbols, self.accounts): self.symbols, self.accounts = symbols, accounts @@ -644,7 +644,7 @@ async def before_call(self, request: dict[str, Any]) -> dict[str, Any] | None: await self.trading.load_instruments(missing, strict=False) unknown = [s for s in request["symbols"] if self.trading.cached_scale(s) is None] if unknown: - self.status("error", f"no reference data for {unknown[:5]}; not subscribed") + self._record_error_and_emit_status(f"no reference data for {unknown[:5]}; not subscribed") self.symbols = [s for s in self.symbols if s not in unknown] request = {**request, "symbols": [s for s in request["symbols"] if s not in unknown]} return request if request["symbols"] else None diff --git a/synpath/ws/predict_fun.py b/synpath/ws/predict_fun.py index f4b02b9..759bf53 100644 --- a/synpath/ws/predict_fun.py +++ b/synpath/ws/predict_fun.py @@ -34,12 +34,13 @@ from ..trading.predict_fun import fill_of_event, order_of_event from ..trading.types import Account from .base import ( + decode_json_or_text, BookEvent, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, QuoteEvent, Stream, VenueEvent, maybe_D, ) VENUE = "predict_fun" +# Mainnet. The venue documents no test-network WebSocket; pass `url=` for one. WS_URL = "wss://ws.predict.fun/ws" -"""Mainnet. The venue documents no test-network WebSocket; pass `url=` for one.""" MARKET_STATE = { "REGISTERED": "open", "OPEN": "open", "UNPAUSED": "open", "PAUSED": "paused", @@ -70,17 +71,17 @@ def __init__(self, *, api_key: str | None = None, url: str | None = None, **kwar ) super().__init__(url or WS_URL, **kwargs) self._key = key + # Topic -> the request id it was last subscribed with; sent again on every connect. self.topics: dict[str, int] = {} - """Topic -> the request id it was last subscribed with; sent again on every connect.""" self._next_id = 1 def headers(self) -> dict[str, str]: return {"x-api-key": self._key} if self._key else {} - def status(self, state: Any, detail: str = "", **kwargs: Any) -> None: + def _emit_status_event(self, state: Any, detail: str = "", **kwargs: Any) -> None: # A wallet JWT travels in a topic string, so it is kept out of anything reported. jwt = getattr(self, "_jwt", None) - super().status(state, detail.replace(jwt, "***") if jwt and detail else detail, **kwargs) + super()._emit_status_event(state, detail.replace(jwt, "***") if jwt and detail else detail, **kwargs) async def _subscribe(self, topic: str) -> None: if topic in self.topics: @@ -116,7 +117,7 @@ def _envelope(self, message: Any) -> tuple[str, Any] | None: else: topic = next((t for t, rid in self.topics.items() if rid == message.get("requestId")), None) if topic: - self.status("subscribed", topic.split("/")[0], key=topic.split("/")[-1] if "Wallet" not in topic else None) + self._emit_status_event("subscribed", topic.split("/")[0], key=topic.split("/")[-1] if "Wallet" not in topic else None) return None topic = str(message.get("topic") or "") if topic == "heartbeat": @@ -125,7 +126,7 @@ def _envelope(self, message: Any) -> tuple[str, Any] | None: return topic, message.get("data") def _refused(self, topic: str, code: str, detail: str) -> None: - self.status("error", f"venue refused {topic.split('/')[0] or 'a request'}: {code} {detail}".strip()) + self._record_error_and_emit_status(f"venue refused {topic.split('/')[0] or 'a request'}: {code} {detail}".strip()) def _later(self, coroutine: Any) -> None: import asyncio @@ -156,11 +157,11 @@ class PredictFunMarketStream(_PredictFunStream): def __init__(self, **kwargs: Any): super().__init__(**kwargs) + # Native market id -> its YES book. self.books: dict[str, LocalBook] = {} - """Native market id -> its YES book.""" self.tickers: set[str] = set() + # Native market id -> the id of the last settled order seen. self._last: dict[str, str] = {} - """Native market id -> the id of the last settled order seen.""" async def watch_order_book(self, market_ids: list[str]) -> None: """Each market's whole book, on every change.""" @@ -201,7 +202,11 @@ def on_disconnect(self) -> None: for book in self.books.values(): book.invalidate() - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: envelope = self._envelope(message) if envelope is None: return [] @@ -226,7 +231,7 @@ def _on_book(self, native: str, data: dict[str, Any]) -> list[Event]: book.replace(_levels(data.get("bids")), _levels(data.get("asks"))) book.timestamp = int(data["updateTimestampMs"]) if data.get("updateTimestampMs") else None if recovering: - self.status("resynced", f"book {market_id}", key=market_id) + self._record_resync_and_emit_status(f"book {market_id}", key=market_id) bids, asks = book.levels() events.append(BookEvent( venue=VENUE, market_id=market_id, side="yes", kind="snapshot", bids=bids, asks=asks, @@ -305,7 +310,7 @@ def _refused(self, topic: str, code: str, detail: str) -> None: self._later(self._renew()) return if code == "invalid_credentials": - self.status("failed", "the wallet JWT was refused (expired or revoked); pass a fresh one") + self._emit_status_event("failed", "the wallet JWT was refused (expired or revoked); pass a fresh one") self._later(self.close()) return super()._refused(topic, code, detail) @@ -313,7 +318,11 @@ def _refused(self, topic: str, code: str, detail: str) -> None: async def _renew(self) -> None: await self._subscribe(f"predictWalletEvents/{await self._token(fresh=True)}") - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: envelope = self._envelope(message) if envelope is None: return [] diff --git a/synpath/ws/smarkets.py b/synpath/ws/smarkets.py index 310e4f6..3ff82fa 100644 --- a/synpath/ws/smarkets.py +++ b/synpath/ws/smarkets.py @@ -42,7 +42,7 @@ from .. import ids from ..base import Capability, RateLimiter -from .base import BookEvent, Event, LocalBook, MarketStatusEvent, QuoteEvent, Stream, now_ms +from .base import BookEvent, Event, LocalBook, MarketStatusEvent, QuoteEvent, Stream, now_ms, decode_json_or_text from .betfair import changed # shared: the levels that differ between two book sides @@ -51,11 +51,11 @@ VENUE = "smarkets" SCALE = Decimal(10_000) +# Seconds between polls: 12 quotes requests a minute, inside the anonymous 20. DEFAULT_INTERVAL = 5.0 -"""Seconds between polls: 12 quotes requests a minute, inside the anonymous 20.""" +# Smarkets markets one stream polls; five quotes requests a poll at most. MAX_MARKETS = 1000 -"""Smarkets markets one stream polls; five quotes requests a poll at most.""" def yes_levels(rows: Any) -> list[tuple[Decimal, Decimal]]: @@ -155,17 +155,17 @@ def __init__( self._own_client = client is None self.quotes_limiter = QUOTES_LIMITER if quotes_limiter == "shared" else quotes_limiter self.limiter = LIMITER if limiter == "shared" else limiter + # Contract's Synpath id -> its YES book. self.books: dict[str, LocalBook] = {} - """Contract's Synpath id -> its YES book.""" self.tickers: set[str] = set() + # Smarkets markets watched as a whole: every contract's book is kept. self.whole_markets: set[str] = set() - """Smarkets markets watched as a whole: every contract's book is kept.""" + # Smarkets market ids whose status changes are passed on; `None` while not watching. self.statuses: set[str] | None = None - """Smarkets market ids whose status changes are passed on; `None` while not watching.""" + # Smarkets market ids polled. self.wanted: set[str] = set() - """Smarkets market ids polled.""" + # Contract id -> Smarkets market id, for markets watched whole. self.contract_market: dict[str, str] = {} - """Contract id -> Smarkets market id, for markets watched whole.""" self.polls = 0 self._top: dict[str, tuple[Any, ...]] = {} self._last_state: dict[str, tuple[str, str | None]] = {} @@ -272,7 +272,7 @@ def _want(self, markets: list[str]) -> set[str]: continue self.wanted.add(market) if refused: - self.status("error", f"more than {MAX_MARKETS} markets on one stream; not polled: {', '.join(sorted(refused))}") + self._record_error_and_emit_status(f"more than {MAX_MARKETS} markets on one stream; not polled: {', '.join(sorted(refused))}") return refused async def watch_order_book(self, market_ids: list[str]) -> None: @@ -331,7 +331,11 @@ def book(self, market_id: str, side: str = "yes") -> LocalBook | None: # -- messages ------------------------------------------------------------- - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict) or message.get("op") != "poll": return [] events: list[Event] = [] @@ -373,7 +377,7 @@ def _book_events(self, synpath_id: str, contract_id: str, quote: dict[str, Any], best_bid=book.best_bid, best_ask=book.best_ask, info=info, )) if recovering: - self.status("resynced", f"book {synpath_id}", key=synpath_id) + self._record_resync_and_emit_status(f"book {synpath_id}", key=synpath_id) else: bid_changes, ask_changes = changed(old_bids, new_bids), changed(old_asks, new_asks) if bid_changes or ask_changes: diff --git a/synpath/ws/sx_bet.py b/synpath/ws/sx_bet.py index 8debdd5..ec06df0 100644 --- a/synpath/ws/sx_bet.py +++ b/synpath/ws/sx_bet.py @@ -49,8 +49,8 @@ ODDS_SCALE = Decimal(10) ** 20 USDC = Decimal(1_000_000) +# Subscriptions one connection takes. MAX_CHANNELS = 512 -"""Subscriptions one connection takes.""" def hash_of(market_id: str) -> str: @@ -127,9 +127,9 @@ def __init__( super().__init__(url or (TESTNET_WS_URL if testnet else WS_URL), **kwargs) self.api_url = api_url or (TESTNET_API_URL if testnet else API_URL) self._api_key = api_key + # A token string, or a callable (sync or async) returning one; for tests + # and for callers who fetch tokens themselves. self._token_source = token - """A token string, or a callable (sync or async) returning one; for tests - and for callers who fetch tokens themselves.""" self.ready = False self.channels: set[str] = set() self._live: set[str] = set() @@ -181,7 +181,7 @@ def on_disconnect(self) -> None: self._renewer.cancel() self._renewer = None - def handle_raw(self, raw: str) -> list[Event] | None: + def handle_raw(self, raw: str) -> list[Event]: events: list[Event] = [] for line in raw.split("\n"): if not line.strip(): @@ -203,14 +203,14 @@ def _message(self, message: dict[str, Any]) -> list[Event]: kind, channel = self._pending.pop(int(message["id"]), ("", "")) if message.get("error"): error = message["error"] - self.status("error", f"venue refused {kind} {channel}: {error.get('code')} {error.get('message')}".strip()) + self._record_error_and_emit_status(f"venue refused {kind} {channel}: {error.get('code')} {error.get('message')}".strip()) if kind == "subscribe": self._live.discard(channel) return [] if kind == "connect": self._on_connected(message.get("connect") or {}) elif kind == "subscribe": - self.status("subscribed", channel, key=channel) + self._emit_status_event("subscribed", channel, key=channel) return self.on_subscribed(channel) return [] push = message.get("push") @@ -220,7 +220,7 @@ def _message(self, message: dict[str, Any]) -> list[Event]: if isinstance(publication, dict) and channel: return self.on_publication(channel, publication.get("data")) if push.get("disconnect"): - self.status("error", f"venue disconnected the stream: {push['disconnect']}") + self._record_error_and_emit_status(f"venue disconnected the stream: {push['disconnect']}") return [] def _on_connected(self, reply: dict[str, Any]) -> None: @@ -237,7 +237,7 @@ async def _renew(self, ttl: int) -> None: try: token = await self.fetch_token() except Exception as exc: # pragma: no cover - the venue's API down - self.status("error", f"could not renew the realtime token: {type(exc).__name__}: {exc}") + self._record_error_and_emit_status(f"could not renew the realtime token: {type(exc).__name__}: {exc}") continue await self.send(self._command("refresh", {"token": token})) @@ -247,7 +247,7 @@ async def resubscribe(self) -> None: return for channel in sorted(self.channels - self._live): if len(self._live) >= MAX_CHANNELS: - self.status("error", f"more than {MAX_CHANNELS} channels on one connection; {channel} not subscribed") + self._record_error_and_emit_status(f"more than {MAX_CHANNELS} channels on one connection; {channel} not subscribed") return self._live.add(channel) await self.send(self._command("subscribe", {"channel": channel}, channel=channel)) @@ -300,13 +300,13 @@ class SxBetMarketStream(_CentrifugoStream): def __init__(self, *, catalog: Any = None, **kwargs: Any): super().__init__(**kwargs) + # An `SxBet` for seeding books from REST; made when first needed. self.catalog = catalog - """An `SxBet` for seeding books from REST; made when first needed.""" + # Hash -> its YES book. self.books: dict[str, LocalBook] = {} - """Hash -> its YES book.""" self.tickers: set[str] = set() + # Hashes whose bets are passed on; `None` while not watching, empty for all. self.trades: set[str] | None = None - """Hashes whose bets are passed on; `None` while not watching, empty for all.""" self.statuses: set[str] | None = None self._versions: dict[str, str] = {} @@ -382,7 +382,7 @@ async def _seed(self, market_hash: str) -> None: catalog._get, "/orderbook-v3/snapshot", {"marketHash": market_hash}, ) except Exception as exc: # pragma: no cover - the venue's API down - self.status("error", f"could not seed book {market_hash}: {type(exc).__name__}: {exc}") + self._record_error_and_emit_status(f"could not seed book {market_hash}: {type(exc).__name__}: {exc}") return if isinstance(payload, dict): for event in self._on_book({**payload, "marketHash": market_hash}): @@ -435,7 +435,7 @@ def _on_book(self, data: dict[str, Any]) -> list[Event]: book.replace(bids, asks) book.timestamp = now_ms() if recovering: - self.status("resynced", f"book {market_id}", key=market_id) + self._record_resync_and_emit_status(f"book {market_id}", key=market_id) levels_bids, levels_asks = book.levels() events.append(BookEvent( venue=VENUE, market_id=market_id, side="yes", kind="snapshot", bids=levels_bids, asks=levels_asks, @@ -545,7 +545,7 @@ def on_publication(self, channel: str, data: Any) -> list[Event]: try: rfq = rfq_of_parlay(row, received_at=received) except ValueError as exc: - self.status("error", f"unreadable parlay request {row.get('marketHash')}: {exc}") + self._record_error_and_emit_status(f"unreadable parlay request {row.get('marketHash')}: {exc}") rfq = None events.append(VenueEvent(venue=VENUE, name="parlay_request", payload={"row": row, "rfq": rfq}, timestamp=received)) diff --git a/tests/test_betfair_ws.py b/tests/test_betfair_ws.py index 4cf7b67..e5fed23 100644 --- a/tests/test_betfair_ws.py +++ b/tests/test_betfair_ws.py @@ -72,11 +72,11 @@ def message(name: str, **changes) -> str: async def authenticated(stream): """A stream past authentication: the frame sent, Betfair's SUCCESS read.""" stream._ws = FakeSocket() - stream._dispatch(message("connection")) + stream._process_raw_frame_and_emit_events(message("connection")) await stream.on_connect() auth = stream._ws.sent[-1] assert auth == {"op": "authentication", "id": auth["id"], "appKey": "APP-KEY-DELAYED", "session": "SESSION-TOKEN-1"} - stream._dispatch(message("auth_success", id=auth["id"])) + stream._process_raw_frame_and_emit_events(message("auth_success", id=auth["id"])) await settle() return stream @@ -220,7 +220,7 @@ async def test_a_refused_session_is_dropped_so_the_next_connection_logs_in(self) stream = stream_with() stream._ws = FakeSocket() await stream.on_connect() - stream._dispatch(message("auth_failure", id=stream._auth_id)) + stream._process_raw_frame_and_emit_events(message("auth_failure", id=stream._auth_id)) assert stream.session._token is None assert drain(stream)[-1].state == "error" @@ -228,7 +228,7 @@ async def test_a_refused_app_key_fails_for_good(self): stream = stream_with() stream._ws = FakeSocket() await stream.on_connect() - stream._dispatch(message("app_key_failure", id=stream._auth_id)) + stream._process_raw_frame_and_emit_events(message("app_key_failure", id=stream._auth_id)) await settle() states = [e.state for e in drain(stream) if isinstance(e, StreamStatusEvent)] assert "failed" in states and stream._closing @@ -240,13 +240,13 @@ async def started(self, **kwargs): await stream.watch_order_book([ARSENAL]) await stream.watch_ticker([CHELSEA]) await authenticated(stream) - stream._dispatch(message("subscribe_success", id=stream._sub_id)) + stream._process_raw_frame_and_emit_events(message("subscribe_success", id=stream._sub_id)) drain(stream) return stream async def test_an_image_then_deltas(self): stream = await self.started() - stream._dispatch(message("image")) + stream._process_raw_frame_and_emit_events(message("image")) events = drain(stream) snapshot = next(e for e in events if isinstance(e, BookEvent)) assert snapshot.market_id == ARSENAL and snapshot.kind == "snapshot" @@ -257,7 +257,7 @@ async def test_an_image_then_deltas(self): assert quote.market_id == CHELSEA and quote.bid == yes_price(3.7) and quote.ask == yes_price(3.65) assert stream.initial_clk == "G1jHgcgNHPgjjcwRj5LFHw==" and stream.clk == "AAAAAAAAAAAAAA==" - stream._dispatch(message("delta")) + stream._process_raw_frame_and_emit_events(message("delta")) delta = next(e for e in drain(stream) if isinstance(e, BookEvent)) assert delta.kind == "delta" and stream.clk == "AAAAAAAAAAAAAB==" # Level 0 of batb moved from 2.08 to 2.10: the old price is gone, the new one in. @@ -273,22 +273,22 @@ async def test_an_image_then_deltas(self): async def test_a_heartbeat_moves_the_clock_only(self): stream = await self.started() - stream._dispatch(message("image")) + stream._process_raw_frame_and_emit_events(message("image")) drain(stream) - stream._dispatch(message("heartbeat")) + stream._process_raw_frame_and_emit_events(message("heartbeat")) assert drain(stream) == [] and stream.clk == "AAAAAAAAAAAAAD==" async def test_reconnect_resubscribes_with_the_clock_and_applies_the_delta(self): stream = await self.started() - stream._dispatch(message("image")) - stream._dispatch(message("delta")) + stream._process_raw_frame_and_emit_events(message("image")) + stream._process_raw_frame_and_emit_events(message("delta")) drain(stream) stream.on_disconnect() assert not stream.book(ARSENAL).ready await authenticated(stream) resubscription = stream._ws.sent[-1] assert resubscription["initialClk"] == "G1jHgcgNHPgjjcwRj5LFHw==" and resubscription["clk"] == "AAAAAAAAAAAAAB==" - stream._dispatch(message("resub_delta")) + stream._process_raw_frame_and_emit_events(message("resub_delta")) events = drain(stream) book = next(e for e in events if isinstance(e, BookEvent)) assert book.kind == "snapshot" and stream.book(ARSENAL).ready @@ -298,7 +298,7 @@ async def test_reconnect_resubscribes_with_the_clock_and_applies_the_delta(self) async def test_a_changed_market_set_starts_a_fresh_image(self): stream = await self.started() - stream._dispatch(message("image")) + stream._process_raw_frame_and_emit_events(message("image")) await stream.watch_order_book(["betfair:1.234567891-47972"]) subscription = stream._ws.sent[-1] assert subscription["marketFilter"]["marketIds"] == ["1.234567890", "1.234567891"] @@ -307,8 +307,8 @@ async def test_a_changed_market_set_starts_a_fresh_image(self): async def test_a_delayed_key_is_warned_about_once(self, caplog): stream = await self.started() with caplog.at_level(logging.WARNING, logger="synpath.ws.betfair"): - stream._dispatch(message("image")) - stream._dispatch(message("resub_delta")) + stream._process_raw_frame_and_emit_events(message("image")) + stream._process_raw_frame_and_emit_events(message("resub_delta")) warnings = [r for r in caplog.records if "conflated" in r.getMessage()] assert len(warnings) == 1 and str(DELAYED_CONFLATE_MS) in warnings[0].getMessage() assert stream.idle_timeout >= DELAYED_CONFLATE_MS / 1000 @@ -326,11 +326,11 @@ async def test_runner_status_changes(self): stream = stream_with() await stream.watch_market_status(["betfair:1.234567890"]) await authenticated(stream) - stream._dispatch(message("image")) + stream._process_raw_frame_and_emit_events(message("image")) opened = [e for e in drain(stream) if isinstance(e, MarketStatusEvent)] assert {e.market_id for e in opened} == {ARSENAL, CHELSEA, "betfair:1.234567890-58805"} assert {e.state for e in opened} == {"open"} - stream._dispatch(message("status_change")) + stream._process_raw_frame_and_emit_events(message("status_change")) changes = {e.market_id: e for e in drain(stream) if isinstance(e, MarketStatusEvent)} assert changes[ARSENAL].state == "paused" and changes[ARSENAL].info["in_play"] is True assert changes["betfair:1.234567890-58805"].state == "settled" @@ -350,7 +350,7 @@ async def test_betfairs_own_refusal_is_reported(self): stream = stream_with() await stream.watch_order_book([ARSENAL]) await authenticated(stream) - stream._dispatch(message("subscription_limit", id=stream._sub_id)) + stream._process_raw_frame_and_emit_events(message("subscription_limit", id=stream._sub_id)) assert "SUBSCRIPTION_LIMIT_EXCEEDED" in drain(stream)[-1].detail diff --git a/tests/test_core_streams.py b/tests/test_core_streams.py index 059cff4..aa92bdb 100644 --- a/tests/test_core_streams.py +++ b/tests/test_core_streams.py @@ -1,7 +1,7 @@ """Streams on the Rust core against the same streams in Python. -A stream hands each raw frame to the Rust core first (`handle_raw`) and to -its Python handler for whatever the core declines. These tests replay real +Each venue adapter owns native/Python selection inside handle_raw(). The +private native helper can decline a frame without mutating its state. These tests replay real recorded traffic through a stream both ways -- once as shipped, once with the core switched off -- and require the same events, the same books and the same pending checks after every frame. Dropped and corrupted frames make the @@ -72,7 +72,7 @@ def _run_now(coroutine): def polymarket_stream(rows, *, rust: bool): stream = PolymarketMarketStream() if not rust: - stream.handle_raw = lambda raw: None + stream._try_process_frame_with_rust = lambda raw: None recorder = Recorder(stream) for row in rows: if row["dir"] == "out" and "assets_ids" in row["msg"]: @@ -112,8 +112,8 @@ def replay_both(raw_frames, rows): rust, rust_rec = polymarket_stream(rows, rust=True) python, python_rec = polymarket_stream(rows, rust=False) for frame in raw_frames: - rust._dispatch(frame) - python._dispatch(frame) + rust._process_raw_frame_and_emit_events(frame) + python._process_raw_frame_and_emit_events(frame) assert [comparable(e) for e in drain(rust)] == [comparable(e) for e in drain(python)], frame[:200] assert book_state(rust) == book_state(python) assert rust._unverified == python._unverified @@ -127,11 +127,11 @@ def test_the_core_handles_book_frames(self): rows = transcript("polymarket_market_transcript.jsonl") stream, _ = polymarket_stream(rows, rust=True) declined = [] - handle = stream.handle - stream.handle = lambda message: declined.append(message) or handle(message) + handle = stream._handle_decoded_message + stream._handle_decoded_message = lambda message: declined.append(message) or handle(message) all_frames = list(frames(rows)) for frame in all_frames: - stream._dispatch(frame) + stream._process_raw_frame_and_emit_events(frame) # Only the trades and the heartbeats reach Python. assert 0 < len(declined) < len(all_frames) / 10 @@ -163,7 +163,7 @@ def test_the_corrupted_replays_reach_the_gap_path(self): roll = rng.random() if roll < 0.08: continue - stream._dispatch(corrupt(frame, rng) if roll < 0.14 else frame) + stream._process_raw_frame_and_emit_events(corrupt(frame, rng) if roll < 0.14 else frame) gaps += len(recorder.tokens) assert gaps > 20 @@ -174,7 +174,7 @@ def test_a_frame_python_would_reject_is_left_to_python(self): token = next(iter(rust.tokens)) bad = json.dumps({"event_type": "book", "asset_id": token, "bids": [{"price": "abc", "size": "1"}], "asks": []}) for stream in (rust, python): - stream._dispatch(bad) + stream._process_raw_frame_and_emit_events(bad) assert [comparable(e) for e in drain(rust)] == [comparable(e) for e in drain(python)] assert [e.state for e in drain(rust)] == [] assert book_state(rust) == book_state(python) @@ -192,7 +192,7 @@ def kalshi_stream(rows, *, rust: bool): ) stream = KalshiStream(KalshiCredentials(key_id="key-1", private_key_pem=pem, env="demo")) if not rust: - stream.handle_raw = lambda raw: None + stream._try_process_frame_with_rust = lambda raw: None commands: list = [] async def send_command(cmd, params): @@ -223,8 +223,8 @@ def replay_kalshi_both(raw_frames, rows): rust, rust_commands = kalshi_stream(rows, rust=True) python, python_commands = kalshi_stream(rows, rust=False) for frame in raw_frames: - rust._dispatch(frame) - python._dispatch(frame) + rust._process_raw_frame_and_emit_events(frame) + python._process_raw_frame_and_emit_events(frame) assert [comparable(e) for e in drain(rust)] == [comparable(e) for e in drain(python)], frame[:200] assert kalshi_book_state(rust) == kalshi_book_state(python) assert {n: c.last_seq for n, c in rust.channels.items()} == {n: c.last_seq for n, c in python.channels.items()} @@ -242,11 +242,11 @@ def test_the_core_handles_book_frames(self): rows = transcript(self.ROWS) stream, _ = kalshi_stream(rows, rust=True) declined = [] - handle = stream.handle - stream.handle = lambda message: declined.append(message) or handle(message) + handle = stream._handle_decoded_message + stream._handle_decoded_message = lambda message: declined.append(message) or handle(message) frames_in = self.kalshi_frames(rows) for frame in frames_in: - stream._dispatch(frame) + stream._process_raw_frame_and_emit_events(frame) book_frames = [f for f in frames_in if '"orderbook_' in f] assert book_frames and not any(m.get("type", "").startswith("orderbook_") for m in declined) @@ -272,7 +272,7 @@ def test_the_drops_reach_the_gap_path(self): stream, commands = kalshi_stream(rows, rust=True) for frame in self.kalshi_frames(rows): if rng.random() > 0.05: - stream._dispatch(frame) + stream._process_raw_frame_and_emit_events(frame) asked += sum(1 for cmd, params in commands if params.get("action") == "get_snapshot") assert asked > 10 @@ -292,7 +292,7 @@ def stream(self, rng, *, rust: bool): stream = OpinionMarketStream(api_key="test-key-0123", catalog=None, resync_interval=None) if not rust: - stream.handle_raw = lambda raw: None + stream._try_process_frame_with_rust = lambda raw: None watched = Watched(market_id="opinion:8453", native="8453", yes_token=self.YES, no_token=self.NO, topic=None) seed = random.Random(rng.random()) for token in (self.YES, self.NO): @@ -328,8 +328,8 @@ def test_random_changes_match(self, seed): if i == 250: for stream in (rust, python): stream.books[self.NO].invalidate() - rust._dispatch(frame) - python._dispatch(frame) + rust._process_raw_frame_and_emit_events(frame) + python._process_raw_frame_and_emit_events(frame) assert [comparable(e) for e in drain(rust)] == [comparable(e) for e in drain(python)], frame assert book_state(rust) == book_state(python) assert rust._pending == python._pending and rust._pending[self.YES] @@ -352,7 +352,7 @@ def stream(self, *, rust: bool): ) stream = PolymarketUSMarketStream(PolymarketUSCredentials(key_id="kid", secret_key=base64.b64encode(raw).decode())) if not rust: - stream.handle_raw = lambda raw: None + stream._try_process_frame_with_rust = lambda raw: None return stream def frames(self, rng, count=200): @@ -385,8 +385,8 @@ def test_random_market_data_matches(self, seed): rng = random.Random(seed) rust, python = self.stream(rust=True), self.stream(rust=False) for frame in self.frames(rng): - rust._dispatch(frame) - python._dispatch(frame) + rust._process_raw_frame_and_emit_events(frame) + python._process_raw_frame_and_emit_events(frame) assert [comparable(e) for e in drain(rust)] == [comparable(e) for e in drain(python)], frame assert book_state(rust) == book_state(python) assert rust.states == python.states diff --git a/tests/test_hyperliquid_ws.py b/tests/test_hyperliquid_ws.py index fc41f5d..15a6a14 100644 --- a/tests/test_hyperliquid_ws.py +++ b/tests/test_hyperliquid_ws.py @@ -115,8 +115,8 @@ def test_a_question_id_is_refused(self): def test_every_book_message_is_a_snapshot(self): stream = HyperliquidMarketStream() asyncio.run(watching(stream, stream.watch_order_book([MARKET]))) - stream._dispatch(json.dumps(WS["l2Book"])) - stream._dispatch(json.dumps(WS["l2Book"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["l2Book"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["l2Book"])) events = [e for e in drain(stream) if isinstance(e, BookEvent)] assert [e.kind for e in events] == ["snapshot", "snapshot"] book = stream.book(MARKET) @@ -128,27 +128,27 @@ def test_the_book_object_stays_the_same(self): stream = HyperliquidMarketStream() asyncio.run(watching(stream, stream.watch_order_book([MARKET]))) before = stream.book(MARKET) - stream._dispatch(json.dumps(WS["l2Book"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["l2Book"])) assert stream.book(MARKET) is before def test_a_reconnect_marks_books_not_ready_until_the_next_message(self): stream = HyperliquidMarketStream() asyncio.run(watching(stream, stream.watch_order_book([MARKET]))) - stream._dispatch(json.dumps(WS["l2Book"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["l2Book"])) stream.on_disconnect() assert not stream.book(MARKET).ready drain(stream) - stream._dispatch(json.dumps(WS["l2Book"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["l2Book"])) states = [e.state for e in drain(stream) if isinstance(e, StreamStatusEvent)] assert stream.book(MARKET).ready and states == ["resynced"] def test_prints_from_before_the_subscription_are_dropped(self): stream = HyperliquidMarketStream() asyncio.run(watching(stream, stream.watch_trades([MARKET]))) - stream._dispatch(json.dumps(WS["trades"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["trades"])) assert not [e for e in drain(stream) if isinstance(e, TradeEvent)] stream.trades_since["#75440"] = 0 - stream._dispatch(json.dumps(WS["trades"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["trades"])) trades = [e for e in drain(stream) if isinstance(e, TradeEvent)] assert len(trades) == len(WS["trades"]["data"]) row = WS["trades"]["data"][0] @@ -158,8 +158,8 @@ def test_prints_from_before_the_subscription_are_dropped(self): def test_quotes_and_volume(self): stream = HyperliquidMarketStream() asyncio.run(watching(stream, stream.watch_ticker([MARKET]))) - stream._dispatch(json.dumps(WS["bbo"])) - stream._dispatch(json.dumps(WS["activeSpotAssetCtx"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["bbo"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["activeSpotAssetCtx"])) bbo, ctx = [e for e in drain(stream) if isinstance(e, QuoteEvent)] assert bbo.bid == Decimal(WS["bbo"]["data"]["bbo"][0]["px"]) assert bbo.ask == Decimal(WS["bbo"]["data"]["bbo"][1]["px"]) @@ -167,9 +167,9 @@ def test_quotes_and_volume(self): def test_acknowledgements_and_pongs(self): stream = HyperliquidMarketStream() - stream._dispatch(json.dumps(WS["subscriptionResponse"])) - stream._dispatch(json.dumps(WS["pong"])) - stream._dispatch(json.dumps({"channel": "error", "data": "Invalid subscription"})) + stream._process_raw_frame_and_emit_events(json.dumps(WS["subscriptionResponse"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["pong"])) + stream._process_raw_frame_and_emit_events(json.dumps({"channel": "error", "data": "Invalid subscription"})) states = [e.state for e in drain(stream) if isinstance(e, StreamStatusEvent)] assert states == ["subscribed", "error"] @@ -198,14 +198,14 @@ def test_subscriptions_name_the_address(self): def test_the_fill_snapshot_is_reported_not_replayed(self): stream = self.user() - stream._dispatch(json.dumps(WS["userFills_snapshot"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["userFills_snapshot"])) events = drain(stream) assert not [e for e in events if isinstance(e, FillEvent)] assert [e.state for e in events if isinstance(e, StreamStatusEvent)] == ["subscribed"] def test_new_fills_and_transfers(self): stream = self.user() - stream._dispatch(json.dumps({"channel": "userFills", "data": {"user": stream.address, "fills": FILLS}})) + stream._process_raw_frame_and_emit_events(json.dumps({"channel": "userFills", "data": {"user": stream.address, "fills": FILLS}})) events = drain(stream) fills = [e for e in events if isinstance(e, FillEvent)] transfers = [e for e in events if isinstance(e, VenueEvent)] @@ -214,14 +214,14 @@ def test_new_fills_and_transfers(self): def test_order_updates(self): stream = self.user() - stream._dispatch(json.dumps({"channel": "orderUpdates", "data": ORDERS})) + stream._process_raw_frame_and_emit_events(json.dumps({"channel": "orderUpdates", "data": ORDERS})) orders = [e for e in drain(stream) if isinstance(e, OrderEvent)] assert [e.native for e in orders] == [row["status"] for row in ORDERS] def test_a_market_filter_narrows(self): stream = self.user() asyncio.run(watching(stream, stream.watch_orders(["hyperliquid:3600"]))) - stream._dispatch(json.dumps({"channel": "orderUpdates", "data": ORDERS})) + stream._process_raw_frame_and_emit_events(json.dumps({"channel": "orderUpdates", "data": ORDERS})) assert not [e for e in drain(stream) if isinstance(e, OrderEvent)] diff --git a/tests/test_limitless_ws.py b/tests/test_limitless_ws.py index a59daa1..cf6634a 100644 --- a/tests/test_limitless_ws.py +++ b/tests/test_limitless_ws.py @@ -51,7 +51,7 @@ def run(stream, *frames, then=None): async def go(): stream._ws = FakeSocket() for raw in frames: - stream._dispatch(raw) + stream._process_raw_frame_and_emit_events(raw) await asyncio.sleep(0) if then is not None: await then diff --git a/tests/test_opinion_ws.py b/tests/test_opinion_ws.py index 5873f26..eda079c 100644 --- a/tests/test_opinion_ws.py +++ b/tests/test_opinion_ws.py @@ -102,7 +102,7 @@ def test_the_key_is_in_the_url(self, catalog): assert market_stream(catalog).url == f"wss://ws.opinion.trade?apikey={KEY}" async def test_the_key_never_reaches_a_status_event(self, catalog): - stream = market_stream(catalog, connect=Connector(OSError(f"cannot reach wss://ws.opinion.trade?apikey={KEY}"))) + stream = market_stream(catalog, websocket_connection_opener=Connector(OSError(f"cannot reach wss://ws.opinion.trade?apikey={KEY}"))) stream.start() await wait_for(lambda: not stream._queue.empty()) await stream.close() @@ -114,7 +114,7 @@ async def test_the_key_never_reaches_a_status_event(self, catalog): class TestBooks: async def test_subscribe_then_snapshot_both_sides(self, catalog, binary): conn = FakeConnection() - stream = market_stream(catalog, connect=Connector(conn)) + stream = market_stream(catalog, websocket_connection_opener=Connector(conn)) await stream.watch_order_book([binary.id]) assert catalog.reads == [] # not connected: nothing to snapshot against stream.start() @@ -137,10 +137,10 @@ async def test_changes_apply_as_the_level_size(self, catalog, binary): await stream.watch_order_book([binary.id]) drain(stream) yes = binary.yes.venue_token_id - [event] = stream.handle(depth(binary, yes, "bids", "0.67", "40")) + [event] = stream._handle_decoded_message(depth(binary, yes, "bids", "0.67", "40")) assert event.kind == "delta" and event.side == "yes" assert event.bids[0].price == D("0.67") and event.best_bid == D("0.67") - stream.handle(depth(binary, yes, "bids", "0.67", "0")) + stream._handle_decoded_message(depth(binary, yes, "bids", "0.67", "0")) assert stream.book(binary.id).best_bid == D("0.66") async def test_changes_during_the_snapshot_are_replayed(self, binary, catalog): @@ -150,7 +150,7 @@ async def test_changes_during_the_snapshot_are_replayed(self, binary, catalog): stream._ws = FakeConnection() task = asyncio.get_running_loop().create_task(stream.watch_order_book([binary.id])) await wait_for(lambda: binary.yes.venue_token_id in stream._pending) - stream.handle(depth(binary, binary.yes.venue_token_id, "asks", "0.70", "5")) + stream._handle_decoded_message(depth(binary, binary.yes.venue_token_id, "asks", "0.70", "5")) gate.set() await task assert stream.book(binary.id).best_ask == D("0.70") @@ -160,11 +160,11 @@ async def test_changes_during_the_snapshot_are_replayed(self, binary, catalog): async def test_changes_before_any_snapshot_are_ignored(self, catalog, binary): stream = market_stream(catalog) await stream.watch_order_book([binary.id]) - assert stream.handle(depth(binary, binary.yes.venue_token_id, "bids", "0.5", "1")) == [] + assert stream._handle_decoded_message(depth(binary, binary.yes.venue_token_id, "bids", "0.5", "1")) == [] async def test_a_reconnect_reads_the_books_again(self, catalog, binary): first, second = FakeConnection(), FakeConnection() - stream = market_stream(catalog, connect=Connector(first, second), backoff_initial=0.001) + stream = market_stream(catalog, websocket_connection_opener=Connector(first, second), backoff_initial=0.001) await stream.watch_order_book([binary.id]) stream.start() await wait_for(lambda: stream.book(binary.id).ready) @@ -205,12 +205,12 @@ async def test_an_option_subscribes_by_topic_and_filters_its_siblings(self, cata assert conn.sent == [{"action": "SUBSCRIBE", "channel": "market.last.trade", "rootMarketId": 337}] sibling = {"marketId": 5343, "tokenId": "x", "side": "Buy", "outcomeSide": 1, "price": "0.4", "shares": "1", "amount": "0.4", "msgType": "market.last.trade"} - assert stream.handle(sibling) == [] + assert stream._handle_decoded_message(sibling) == [] async def test_a_no_trade_is_reported_on_the_yes_leg(self, catalog, binary): stream = market_stream(catalog) await stream.watch_trades([binary.id]) - [trade] = stream.handle({"tokenId": binary.no.venue_token_id, "side": "Buy", "outcomeSide": 2, "price": "0.15", + [trade] = stream._handle_decoded_message({"tokenId": binary.no.venue_token_id, "side": "Buy", "outcomeSide": 2, "price": "0.15", "shares": "10", "amount": "1.5", "marketId": 8453, "msgType": "market.last.trade"}) assert isinstance(trade, TradeEvent) assert trade.price == D("0.85") and trade.taker_side == Side.SELL and trade.quantity == D("10") @@ -218,26 +218,26 @@ async def test_a_no_trade_is_reported_on_the_yes_leg(self, catalog, binary): async def test_a_split_is_not_a_trade(self, catalog, binary): stream = market_stream(catalog) await stream.watch_trades([binary.id]) - [event] = stream.handle({"side": "Split", "outcomeSide": 1, "price": "0.5", "shares": "10", + [event] = stream._handle_decoded_message({"side": "Split", "outcomeSide": 1, "price": "0.5", "shares": "10", "marketId": 8453, "msgType": "market.last.trade"}) assert isinstance(event, VenueEvent) and event.name == "split" async def test_last_price_is_a_quote_on_its_side(self, catalog, binary): stream = market_stream(catalog) await stream.watch_ticker([binary.id]) - [quote] = stream.handle({"tokenId": binary.no.venue_token_id, "outcomeSide": 2, "price": "0.15", + [quote] = stream._handle_decoded_message({"tokenId": binary.no.venue_token_id, "outcomeSide": 2, "price": "0.15", "marketId": 8453, "msgType": "market.last.price"}) assert isinstance(quote, QuoteEvent) and quote.side == "no" and quote.last == D("0.15") async def test_venue_errors_are_reported_and_acks_are_not(self, catalog): stream = market_stream(catalog) - assert stream.handle({"code": 400, "message": "invalid channel"}) == [] - assert stream.handle({"code": 0, "message": "ok"}) == [] + assert stream._handle_decoded_message({"code": 400, "message": "invalid channel"}) == [] + assert stream._handle_decoded_message({"code": 0, "message": "ok"}) == [] assert [e.state for e in of(drain(stream), StreamStatusEvent)] == ["error"] async def test_the_heartbeat_frame_is_sent(self, catalog): conn = FakeConnection() - stream = market_stream(catalog, connect=Connector(conn), ping_interval=0.01) + stream = market_stream(catalog, websocket_connection_opener=Connector(conn), ping_interval=0.01) stream.start() await wait_for(lambda: HEARTBEAT in conn.sent or json.loads(HEARTBEAT) in conn.sent) await stream.close() @@ -279,7 +279,7 @@ async def test_orders_and_fills_subscribe_per_topic(self, catalog, option, binar async def test_an_order_update_is_an_order_on_the_yes_leg(self, catalog): stream = OpinionUserStream(api_key=KEY, catalog=catalog) - [event] = stream.handle(ORDER_UPDATE) + [event] = stream._handle_decoded_message(ORDER_UPDATE) assert isinstance(event, OrderEvent) and event.native == "orderConfirm" order = event.order assert order.market_id == "opinion:2770" and order.side == Side.BUY and order.price == D("0.15") @@ -289,13 +289,13 @@ async def test_an_order_update_is_an_order_on_the_yes_leg(self, catalog): async def test_a_no_order_is_a_sell_at_the_complement(self, catalog): stream = OpinionUserStream(api_key=KEY, catalog=catalog) - [event] = stream.handle({**ORDER_UPDATE, "outcomeSide": 2, "status": 3}) + [event] = stream._handle_decoded_message({**ORDER_UPDATE, "outcomeSide": 2, "status": 3}) assert event.order.side == Side.SELL and event.order.price == D("0.85") assert event.order.status == OrderStatus.CANCELED and event.order.remaining == 0 async def test_a_trade_record_is_a_confirmed_fill(self, catalog): stream = OpinionUserStream(api_key=KEY, catalog=catalog) - [event] = stream.handle(TRADE_RECORD) + [event] = stream._handle_decoded_message(TRADE_RECORD) fill = event.fill assert isinstance(event, FillEvent) assert fill.id == TRADE_RECORD["tradeNo"] and fill.order_id == TRADE_RECORD["orderId"] @@ -304,17 +304,17 @@ async def test_a_trade_record_is_a_confirmed_fill(self, catalog): async def test_a_failed_trade_is_reported_failed(self, catalog): stream = OpinionUserStream(api_key=KEY, catalog=catalog) - [event] = stream.handle({**TRADE_RECORD, "status": 6}) + [event] = stream._handle_decoded_message({**TRADE_RECORD, "status": 6}) assert event.fill.settlement == SettlementState.FAILED async def test_a_merge_is_not_a_fill(self, catalog): stream = OpinionUserStream(api_key=KEY, catalog=catalog) - [event] = stream.handle({**TRADE_RECORD, "side": "Merge"}) + [event] = stream._handle_decoded_message({**TRADE_RECORD, "side": "Merge"}) assert isinstance(event, VenueEvent) and event.name == "merge" async def test_a_reconnect_asks_for_reconciliation(self, catalog): first, second = FakeConnection(), FakeConnection() - stream = OpinionUserStream(api_key=KEY, catalog=catalog, connect=Connector(first, second), backoff_initial=0.001) + stream = OpinionUserStream(api_key=KEY, catalog=catalog, websocket_connection_opener=Connector(first, second), backoff_initial=0.001) stream.start() await wait_for(lambda: stream._ws is first) first.feed(ConnectionError("dropped")) diff --git a/tests/test_predict_fun_ws.py b/tests/test_predict_fun_ws.py index 3c78ec4..b453a07 100644 --- a/tests/test_predict_fun_ws.py +++ b/tests/test_predict_fun_ws.py @@ -23,6 +23,7 @@ NATIVE = BOOK["topic"].split("/")[1] MARKET = f"predict_fun:{NATIVE}" +# The venue's documented example of a wallet event. BASE_EVENT = { "type": "orderAccepted", "orderId": "123456789", "orderHash": "0xabc", "walletAddress": "0xdef", "timestamp": 1736696400000, @@ -30,7 +31,6 @@ "quoteType": "BID", "quantity": "100.000", "quantityFilled": "0.000", "price": "0.620", "value": "62.00", "valueFilled": "0.00", "strategyType": "LIMIT", "categorySlug": "politics"}, } -"""The venue's documented example of a wallet event.""" def event(kind: str, **extra) -> dict: @@ -125,7 +125,7 @@ def test_subscriptions_are_one_topic_each_and_sent_once(self): def test_every_book_message_is_a_snapshot_in_yes_prices(self): stream = market_stream() asyncio.run(watching(stream, stream.watch_order_book([MARKET]))) - stream._dispatch(json.dumps(BOOK)) + stream._process_raw_frame_and_emit_events(json.dumps(BOOK)) [book_event] = [e for e in drain(stream) if isinstance(e, BookEvent)] assert book_event.kind == "snapshot" and book_event.market_id == MARKET book = stream.book(MARKET) @@ -135,8 +135,8 @@ def test_every_book_message_is_a_snapshot_in_yes_prices(self): def test_the_ticker_reports_a_new_last_price_once(self): stream = market_stream() asyncio.run(watching(stream, stream.watch_ticker([MARKET]))) - stream._dispatch(json.dumps(BOOK)) - stream._dispatch(json.dumps(BOOK)) + stream._process_raw_frame_and_emit_events(json.dumps(BOOK)) + stream._process_raw_frame_and_emit_events(json.dumps(BOOK)) first, second = [e for e in drain(stream) if isinstance(e, QuoteEvent)] settled = BOOK["data"]["lastOrderSettled"] expected = Decimal(settled["price"]) if settled["outcome"] == "Yes" else 1 - Decimal(settled["price"]) @@ -145,8 +145,8 @@ def test_the_ticker_reports_a_new_last_price_once(self): def test_market_and_trading_status(self): stream = market_stream() - stream._dispatch(json.dumps(WS["predictMarketStatus"])) - stream._dispatch(json.dumps(WS["predictTradingStatus"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["predictMarketStatus"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["predictTradingStatus"])) states = [e.state for e in drain(stream) if isinstance(e, MarketStatusEvent)] assert states == ["open", "open"] @@ -155,7 +155,7 @@ def test_heartbeats_are_echoed_exactly(self): async def run(): stream._ws = FakeSocket() - stream._dispatch(json.dumps(WS["heartbeat"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["heartbeat"])) await asyncio.sleep(0) return stream._ws.sent @@ -164,14 +164,14 @@ async def run(): def test_a_refused_subscription_is_reported(self): stream = market_stream() asyncio.run(watching(stream, stream.watch_order_book([MARKET]))) - stream._dispatch(json.dumps({"type": "R", "requestId": 1, "success": False, + stream._process_raw_frame_and_emit_events(json.dumps({"type": "R", "requestId": 1, "success": False, "error": {"code": "invalid_topic", "message": "unknown topic"}})) assert [e.state for e in drain(stream) if isinstance(e, StreamStatusEvent)] == ["error"] def test_a_reconnect_marks_books_not_ready(self): stream = market_stream() asyncio.run(watching(stream, stream.watch_order_book([MARKET]))) - stream._dispatch(json.dumps(BOOK)) + stream._process_raw_frame_and_emit_events(json.dumps(BOOK)) stream.on_disconnect() assert not stream.book(MARKET).ready @@ -181,13 +181,14 @@ def test_the_jwt_is_the_topic_and_never_reported(self): stream = PredictFunUserStream(api_key="test-key", jwt="secret.jwt.value") sent = asyncio.run(watching(stream, stream.watch_orders())) assert sent[0]["params"] == ["predictWalletEvents/secret.jwt.value"] - stream.status("error", "refused predictWalletEvents/secret.jwt.value") + stream._record_error_and_emit_status("refused predictWalletEvents/secret.jwt.value") assert "secret.jwt.value" not in drain(stream)[-1].detail + assert stream.stats.errors == 1 def test_events_become_orders_and_fills(self): stream = PredictFunUserStream(api_key="test-key", jwt="j") fill = {"executedPriceWei": str(62 * 10**16), "executedSizeWei": str(10**19), "executedValueWei": "0"} - stream._dispatch(json.dumps({"type": "M", "topic": "predictWalletEvents/j", + stream._process_raw_frame_and_emit_events(json.dumps({"type": "M", "topic": "predictWalletEvents/j", "data": event("orderTransactionSuccess", settlementId="s", fill=fill)})) events = drain(stream) assert [type(e) for e in events] == [OrderEvent, FillEvent] @@ -200,7 +201,7 @@ def test_an_expired_jwt_is_renewed_from_its_source(self): async def run(): sent = await watching(stream, stream.watch_orders()) request_id = sent[0]["requestId"] - stream._dispatch(json.dumps({"type": "R", "requestId": request_id, "success": False, + stream._process_raw_frame_and_emit_events(json.dumps({"type": "R", "requestId": request_id, "success": False, "error": {"code": "invalid_credentials"}})) await asyncio.sleep(0) await asyncio.sleep(0) @@ -221,7 +222,7 @@ async def source(*, fresh: bool = False): async def run(): sent = await watching(stream, stream.watch_orders()) - stream._dispatch(json.dumps({"type": "R", "requestId": sent[0]["requestId"], "success": False, + stream._process_raw_frame_and_emit_events(json.dumps({"type": "R", "requestId": sent[0]["requestId"], "success": False, "error": {"code": "invalid_credentials"}})) for _ in range(3): await asyncio.sleep(0) diff --git a/tests/test_smarkets_ws.py b/tests/test_smarkets_ws.py index d59cfe0..15e4ba2 100644 --- a/tests/test_smarkets_ws.py +++ b/tests/test_smarkets_ws.py @@ -92,13 +92,13 @@ def message(self, quotes, **extra): async def test_a_snapshot_then_only_what_changed(self): s, _ = stream() await s.watch_order_book([FULHAM]) - first = books(s.handle(self.message(QUOTES))) + first = books(s._handle_decoded_message(self.message(QUOTES))) assert [e.kind for e in first] == ["snapshot"] assert first[0].best_bid == Decimal("0.5") and first[0].best_ask == Decimal("0.5155") assert first[0].info["delayed"] is True - assert s.handle(self.message(QUOTES)) == [] + assert s._handle_decoded_message(self.message(QUOTES)) == [] changed = moved(QUOTES, "456060183", offers=[{"price": 5128, "quantity": 20000}]) - delta = books(s.handle(self.message(changed))) + delta = books(s._handle_decoded_message(self.message(changed))) assert [e.kind for e in delta] == ["delta"] assert {(level.price, level.size) for level in delta[0].asks} == { (Decimal("0.5128"), Decimal("2")), (Decimal("0.5155"), Decimal("0")), @@ -110,7 +110,7 @@ async def test_a_whole_market_keeps_every_contract(self): s, api = stream() await s._open() await s.watch_order_book(["smarkets:164973231"]) - events = s.handle(await s.poll()) + events = s._handle_decoded_message(await s.poll()) assert "/v3/markets/164973231/contracts/" in api.paths() assert {e.market_id for e in books(events)} == { "smarkets:164973231-456060183", "smarkets:164973231-456060184", "smarkets:164973231-456060185", @@ -119,20 +119,20 @@ async def test_a_whole_market_keeps_every_contract(self): async def test_a_ticker_fires_when_the_top_moves(self): s, _ = stream() await s.watch_ticker([FULHAM]) - first = s.handle(self.message(QUOTES)) + first = s._handle_decoded_message(self.message(QUOTES)) assert len(first) == 1 and isinstance(first[0], QuoteEvent) and first[0].bid == Decimal("0.5") - assert s.handle(self.message(QUOTES)) == [] + assert s._handle_decoded_message(self.message(QUOTES)) == [] deeper = moved(QUOTES, "456060183", bids=QUOTES["456060183"]["bids"][:1]) - assert s.handle(self.message(deeper)) == [] # only a lower level went - assert len(s.handle(self.message(moved(QUOTES, "456060183", bids=[])))) == 1 + assert s._handle_decoded_message(self.message(deeper)) == [] # only a lower level went + assert len(s._handle_decoded_message(self.message(moved(QUOTES, "456060183", bids=[])))) == 1 async def test_after_a_drop_the_next_poll_is_a_snapshot_and_resynced(self): s, _ = stream() await s.watch_order_book([FULHAM]) - s.handle(self.message(QUOTES)) + s._handle_decoded_message(self.message(QUOTES)) s.on_disconnect() assert not s.book(FULHAM).ready - assert [e.kind for e in books(s.handle(self.message(QUOTES)))] == ["snapshot"] + assert [e.kind for e in books(s._handle_decoded_message(self.message(QUOTES)))] == ["snapshot"] status = s._queue.get_nowait() assert isinstance(status, StreamStatusEvent) and status.state == "resynced" @@ -150,21 +150,21 @@ async def test_contract_states_are_read_every_few_polls_and_passed_on_change(sel await s._open() market = HALTED["markets"][0]["id"] await s.watch_market_status([f"smarkets:{market}"]) - first = s.handle(await s.poll()) + first = s._handle_decoded_message(await s.poll()) statuses = [e for e in first if isinstance(e, MarketStatusEvent)] assert {e.state for e in statuses} == {"paused"} and len(statuses) == 2 assert f"/v3/markets/{market}/" in api.paths() reads = len(api.requests) - assert s.handle(await s.poll()) == [] and len(api.requests) == reads + 1 # quotes only - s.handle(await s.poll()) - assert s.handle(await s.poll()) == [] # read again, unchanged + assert s._handle_decoded_message(await s.poll()) == [] and len(api.requests) == reads + 1 # quotes only + s._handle_decoded_message(await s.poll()) + assert s._handle_decoded_message(await s.poll()) == [] # read again, unchanged async def test_settled_contracts_carry_their_result(self): s, _ = stream() await s._open() market = SETTLED["markets"][0]["id"] await s.watch_market_status([f"smarkets:{market}"]) - events = [e for e in s.handle(await s.poll()) if isinstance(e, MarketStatusEvent)] + events = [e for e in s._handle_decoded_message(await s.poll()) if isinstance(e, MarketStatusEvent)] assert sorted(e.result for e in events) == ["no", "no", "yes"] assert all(e.state == "settled" for e in events) diff --git a/tests/test_sx_bet_parlays.py b/tests/test_sx_bet_parlays.py index 7e0adae..7b39ef3 100644 --- a/tests/test_sx_bet_parlays.py +++ b/tests/test_sx_bet_parlays.py @@ -146,7 +146,7 @@ def fake_stream() -> tuple[SxBetParlayStream, FakeSocket]: async def connect(url, **kw): return socket - return SxBetParlayStream(token="tok", connect=connect), socket + return SxBetParlayStream(token="tok", websocket_connection_opener=connect), socket def push(data) -> str: diff --git a/tests/test_sx_bet_ws.py b/tests/test_sx_bet_ws.py index ac42918..0fe11c4 100644 --- a/tests/test_sx_bet_ws.py +++ b/tests/test_sx_bet_ws.py @@ -71,14 +71,14 @@ async def connected(stream): await stream.on_connect() connect = stream._ws.sent[-1] assert connect["connect"]["token"] == "tok" - stream._dispatch(json.dumps({"id": connect["id"], "connect": {"client": "c", "ping": 25, "pong": True}})) + stream._process_raw_frame_and_emit_events(json.dumps({"id": connect["id"], "connect": {"client": "c", "ping": 25, "pong": True}})) await settle() return stream def confirm_subscriptions(stream): for command in [c for c in stream._ws.sent if "subscribe" in c]: - stream._dispatch(json.dumps({"id": command["id"], "subscribe": {}})) + stream._process_raw_frame_and_emit_events(json.dumps({"id": command["id"], "subscribe": {}})) class TestRecords: @@ -127,7 +127,7 @@ def test_pings_are_answered(self): async def go(): stream = await connected(stream_with()) before = len(stream._ws.sent) - stream._dispatch("{}") + stream._process_raw_frame_and_emit_events("{}") await settle() return stream._ws.sent[before:] @@ -138,7 +138,7 @@ async def go(): stream = await connected(stream_with()) await stream.watch_ticker([MARKET]) body = {**BOOK, "version": "9" * 30} - stream._dispatch(push(f"orderbook_v3:{HASH}", body) + "\n" + "{}") + stream._process_raw_frame_and_emit_events(push(f"orderbook_v3:{HASH}", body) + "\n" + "{}") await settle() return stream._ws.sent, drain(stream) @@ -150,8 +150,8 @@ async def go(): stream = await connected(stream_with()) await stream.watch_order_book([MARKET]) drain(stream) - stream._dispatch(push(f"orderbook_v3:{HASH}", {**BOOK, "version": "2"})) - stream._dispatch(push(f"orderbook_v3:{HASH}", {**BOOK, "version": "1", "outcomeOne": []})) + stream._process_raw_frame_and_emit_events(push(f"orderbook_v3:{HASH}", {**BOOK, "version": "2"})) + stream._process_raw_frame_and_emit_events(push(f"orderbook_v3:{HASH}", {**BOOK, "version": "1", "outcomeOne": []})) return drain(stream) books = [e for e in asyncio.run(go()) if isinstance(e, BookEvent)] @@ -162,7 +162,7 @@ async def go(): stream = await connected(stream_with()) await stream.watch_trades() command = [c for c in stream._ws.sent if "subscribe" in c][-1] - stream._dispatch(json.dumps({"id": command["id"], "error": {"code": 103, "message": "permission denied"}})) + stream._process_raw_frame_and_emit_events(json.dumps({"id": command["id"], "error": {"code": 103, "message": "permission denied"}})) return stream, drain(stream) stream, events = asyncio.run(go()) @@ -175,7 +175,7 @@ async def go(): stream._ws = FakeSocket() await stream.on_connect() connect = stream._ws.sent[-1] - stream._dispatch(json.dumps({"id": connect["id"], "connect": {"expires": True, "ttl": 1}})) + stream._process_raw_frame_and_emit_events(json.dumps({"id": connect["id"], "connect": {"expires": True, "ttl": 1}})) await asyncio.sleep(5.2) sent = stream._ws.sent stream.on_disconnect() @@ -192,8 +192,8 @@ def test_trades_are_filtered_to_the_markets_asked_for(self): async def go(): stream = await connected(stream_with()) await stream.watch_trades([MARKET]) - stream._dispatch(push("recent_trades_v3:global", {"trade": other})) - stream._dispatch(push("recent_trades_v3:global", {"trade": mine})) + stream._process_raw_frame_and_emit_events(push("recent_trades_v3:global", {"trade": other})) + stream._process_raw_frame_and_emit_events(push("recent_trades_v3:global", {"trade": mine})) return drain(stream) trades = [e for e in asyncio.run(go()) if isinstance(e, TradeEvent)] @@ -205,7 +205,7 @@ def test_every_market_change_when_none_are_named(self): async def go(): stream = await connected(stream_with()) await stream.watch_market_status() - stream._dispatch(push("markets:global", rows)) + stream._process_raw_frame_and_emit_events(push("markets:global", rows)) return drain(stream) states = [(e.market_id, e.state) for e in asyncio.run(go()) if isinstance(e, MarketStatusEvent)] diff --git a/tests/test_ws.py b/tests/test_ws.py index a300533..834e4e9 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -27,7 +27,7 @@ from synpath.trading.credentials import KalshiCredentials, PolymarketUSCredentials from synpath.trading.types import Liquidity, OrderStatus, PositionSide, SettlementState, Side from synpath.ws.base import ( - BalanceEvent, BookEvent, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, Stream, + decode_json_or_text, BalanceEvent, BookEvent, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, Stream, StreamStatusEvent, TradeEvent, VenueEvent, ) from synpath.ws.kalshi import KalshiStream @@ -157,7 +157,10 @@ def __init__(self, **kwargs): async def on_connect(self): await self.send({"subscribe": self.subscriptions}) - def handle(self, message): + def handle_raw(self, raw): + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message): if isinstance(message, dict) and "value" in message: return [VenueEvent(venue="test", name="value", payload=message)] if isinstance(message, dict) and "boom" in message: @@ -166,9 +169,50 @@ def handle(self, message): class TestStreamLifecycle: + async def test_connection_events_observe_complete_transitions_and_shutdown_is_quiet(self): + class ObservedEcho(Echo): + def __init__(self, **kwargs): + self.observations = [] + self.cleanup_calls = 0 + super().__init__(**kwargs) + + def on_disconnect(self): + self.cleanup_calls += 1 + + def emit(self, event): + if isinstance(event, StreamStatusEvent): + # Inspect state at publication, rather than after the whole + # receive loop has had time to finish its transition. + self.observations.append(( + event.state, self.connected.is_set(), self.stats.connects, + self.stats.disconnects, self.cleanup_calls, + )) + super().emit(event) + + first = FakeConnection([{"value": 1}], close_after=True) + second = FakeConnection([{"value": 2}]) + stream = ObservedEcho( + websocket_connection_opener=Connector(first, second), backoff_initial=0.001, backoff_max=0.002, + ) + try: + stream.start() + await wait_for(lambda: stream.stats.messages == 2) + assert stream.observations == [ + ("connected", True, 1, 0, 0), + ("disconnected", False, 1, 1, 1), + ("connected", True, 2, 1, 1), + ] + finally: + await stream.close() + # Deliberate close still records and cleans up the connection, without + # telling consumers that an unexpected disconnect/retry occurred. + assert len(stream.observations) == 3 + assert not stream.connected.is_set() + assert stream.stats.disconnects == stream.cleanup_calls == 2 + async def test_subscribes_on_connect_and_delivers_events(self): conn = FakeConnection([{"value": 1}, {"value": 2}]) - stream = Echo(connect=Connector(conn), backoff_initial=0.001) + stream = Echo(websocket_connection_opener=Connector(conn), backoff_initial=0.001) stream.start() await wait_for(lambda: stream.stats.messages == 2) events = drain(stream) @@ -180,7 +224,7 @@ async def test_subscribes_on_connect_and_delivers_events(self): async def test_reconnects_and_resubscribes(self): first = FakeConnection([{"value": 1}], close_after=True) second = FakeConnection([{"value": 2}]) - stream = Echo(connect=Connector(first, second), backoff_initial=0.001, backoff_max=0.002) + stream = Echo(websocket_connection_opener=Connector(first, second), backoff_initial=0.001, backoff_max=0.002) stream.start() await wait_for(lambda: stream.stats.connects == 2 and stream.stats.messages == 2) events = drain(stream) @@ -193,7 +237,7 @@ async def test_reconnects_and_resubscribes(self): async def test_a_private_stream_asks_for_reconciliation_after_a_reconnect(self): class Private(Echo): private = True - stream = Private(connect=Connector(FakeConnection([], close_after=True), FakeConnection([])), backoff_initial=0.001, backoff_max=0.002) + stream = Private(websocket_connection_opener=Connector(FakeConnection([], close_after=True), FakeConnection([])), backoff_initial=0.001, backoff_max=0.002) stream.start() await wait_for(lambda: stream.stats.connects == 2) connected = [e for e in of(drain(stream), StreamStatusEvent) if e.state == "connected"] @@ -201,7 +245,7 @@ class Private(Echo): await stream.close() async def test_a_silent_connection_is_replaced(self): - stream = Echo(connect=Connector(FakeConnection([]), FakeConnection([{"value": 9}])), idle_timeout=0.05, backoff_initial=0.001, backoff_max=0.002) + stream = Echo(websocket_connection_opener=Connector(FakeConnection([]), FakeConnection([{"value": 9}])), idle_timeout=0.05, backoff_initial=0.001, backoff_max=0.002) stream.start() await wait_for(lambda: stream.stats.messages == 1) disconnected = [e for e in of(drain(stream), StreamStatusEvent) if e.state == "disconnected"] @@ -213,7 +257,7 @@ async def test_a_connection_that_died_while_the_machine_slept_is_replaced(self): timeout counts no time while the connection dies. The wall clock still moves, and the stream drops the connection when it wakes.""" first, second = FakeConnection([]), FakeConnection([{"value": 9}]) - stream = Echo(connect=Connector(first, second), idle_timeout=4.0, backoff_initial=0.001, backoff_max=0.002) + stream = Echo(websocket_connection_opener=Connector(first, second), idle_timeout=4.0, backoff_initial=0.001, backoff_max=0.002) stream.start() await wait_for(lambda: stream.stats.connects == 1) stream._last_seen -= 600_000 # ten minutes of sleep @@ -224,7 +268,7 @@ async def test_a_connection_that_died_while_the_machine_slept_is_replaced(self): await stream.close() async def test_failed_connects_back_off_then_succeed(self): - stream = Echo(connect=Connector(OSError("refused"), OSError("refused"), FakeConnection([{"value": 1}])), backoff_initial=0.001, backoff_max=0.002) + stream = Echo(websocket_connection_opener=Connector(OSError("refused"), OSError("refused"), FakeConnection([{"value": 1}])), backoff_initial=0.001, backoff_max=0.002) stream.start() await wait_for(lambda: stream.stats.messages == 1) states = [e.state for e in of(drain(stream), StreamStatusEvent)] @@ -233,7 +277,7 @@ async def test_failed_connects_back_off_then_succeed(self): async def test_an_unreadable_message_does_not_end_the_stream(self): conn = FakeConnection([{"boom": 1}, {"value": 2}, "not json"]) - stream = Echo(connect=Connector(conn)) + stream = Echo(websocket_connection_opener=Connector(conn)) stream.start() await wait_for(lambda: stream.stats.messages == 3) events = drain(stream) @@ -242,7 +286,7 @@ async def test_an_unreadable_message_does_not_end_the_stream(self): await stream.close() async def test_iteration_ends_on_close(self): - stream = Echo(connect=Connector(FakeConnection([{"value": 1}]))) + stream = Echo(websocket_connection_opener=Connector(FakeConnection([{"value": 1}]))) seen = [] async def consume(): @@ -256,14 +300,14 @@ async def consume(): async def test_a_closed_stream_does_not_reconnect_when_iterated(self): connector = Connector(FakeConnection([{"value": 1}]), FakeConnection([{"value": 2}])) - stream = Echo(connect=connector) + stream = Echo(websocket_connection_opener=connector) stream.start() await wait_for(lambda: stream.stats.messages == 1) await stream.close() drained = await asyncio.wait_for(_collect_all(stream), 1) # ends: the close marker is queued assert [e.payload["value"] for e in drained if isinstance(e, VenueEvent)] == [1] await asyncio.sleep(0.01) - assert len(connector.opened) == 1 and stream._task is None + assert len(connector.opened) == 1 and stream._receiver_task is None async def test_a_real_server_dropping_the_client(self): websockets = pytest.importorskip("websockets") @@ -329,7 +373,7 @@ def replay_kalshi(stream: KalshiStream, rows: list[dict], *, skip=lambda row: Fa stream.books.setdefault(ticker, LocalBook()) for row in rows: if row["dir"] == "in" and not skip(row): - stream._dispatch(json.dumps(row["msg"])) + stream._process_raw_frame_and_emit_events(json.dumps(row["msg"])) class TestKalshi: @@ -414,7 +458,7 @@ async def test_subscribing_before_and_after_the_server_names_the_channel(self, k await kalshi.watch_order_book(["A"]) await kalshi.watch_order_book(["B"]) # before the sid is known assert conn.sent == [{"id": 1, "cmd": "subscribe", "params": {"channels": ["orderbook_delta"], "market_tickers": ["A"]}}] - kalshi._dispatch(json.dumps({"type": "subscribed", "id": 1, "msg": {"channel": "orderbook_delta", "sid": 7}})) + kalshi._process_raw_frame_and_emit_events(json.dumps({"type": "subscribed", "id": 1, "msg": {"channel": "orderbook_delta", "sid": 7}})) await asyncio.sleep(0) assert conn.sent[1]["params"] == {"sids": [7], "market_tickers": ["B"], "action": "add_markets"} await kalshi.watch_order_book(["C", "A"]) @@ -436,23 +480,23 @@ async def test_buffer_overflow_restarts_the_subscription(self, kalshi): conn = FakeConnection() kalshi._ws = conn await kalshi.watch_order_book(["A"]) - kalshi._dispatch(json.dumps({"type": "subscribed", "id": 1, "msg": {"channel": "orderbook_delta", "sid": 3}})) - kalshi._dispatch(json.dumps({"type": "orderbook_snapshot", "sid": 3, "seq": 1, "msg": {"market_ticker": "A", "yes_dollars_fp": [["0.4000", "1.00"]]}})) - kalshi._dispatch(json.dumps({"type": "error", "sid": 3, "msg": {"code": 25, "msg": "Subscription buffer overflow"}})) + kalshi._process_raw_frame_and_emit_events(json.dumps({"type": "subscribed", "id": 1, "msg": {"channel": "orderbook_delta", "sid": 3}})) + kalshi._process_raw_frame_and_emit_events(json.dumps({"type": "orderbook_snapshot", "sid": 3, "seq": 1, "msg": {"market_ticker": "A", "yes_dollars_fp": [["0.4000", "1.00"]]}})) + kalshi._process_raw_frame_and_emit_events(json.dumps({"type": "error", "sid": 3, "msg": {"code": 25, "msg": "Subscription buffer overflow"}})) await asyncio.sleep(0) assert [f["cmd"] for f in conn.sent[1:]] == ["unsubscribe", "subscribe"] assert not kalshi.books["A"].ready assert [e.state for e in of(drain(kalshi), StreamStatusEvent)][-1] == "gap" def test_lifecycle(self, kalshi): - events = kalshi.handle({"type": "market_lifecycle_v2", "sid": 1, "seq": 1, "msg": {"market_ticker": "A", "event_type": "deactivated", "is_deactivated": True}}) - events += kalshi.handle({"type": "market_lifecycle_v2", "sid": 1, "seq": 2, "msg": {"market_ticker": "A", "event_type": "activated", "is_deactivated": False}}) - events += kalshi.handle({"type": "market_lifecycle_v2", "sid": 1, "seq": 3, "msg": {"market_ticker": "A", "event_type": "determined", "result": "yes", "determination_ts": 1789660000}}) + events = kalshi._handle_decoded_message({"type": "market_lifecycle_v2", "sid": 1, "seq": 1, "msg": {"market_ticker": "A", "event_type": "deactivated", "is_deactivated": True}}) + events += kalshi._handle_decoded_message({"type": "market_lifecycle_v2", "sid": 1, "seq": 2, "msg": {"market_ticker": "A", "event_type": "activated", "is_deactivated": False}}) + events += kalshi._handle_decoded_message({"type": "market_lifecycle_v2", "sid": 1, "seq": 3, "msg": {"market_ticker": "A", "event_type": "determined", "result": "yes", "determination_ts": 1789660000}}) assert [(e.state, e.result) for e in events] == [("paused", None), ("open", None), ("determined", "yes")] assert events[2].timestamp == 1789660000000 def test_no_leg_view(self, kalshi): - kalshi.handle({"type": "orderbook_snapshot", "sid": 1, "seq": 1, "msg": { + kalshi._handle_decoded_message({"type": "orderbook_snapshot", "sid": 1, "seq": 1, "msg": { "market_ticker": "A", "yes_dollars_fp": [["0.4000", "5.00"]], "no_dollars_fp": [["0.5500", "3.00"]]}}) assert kalshi.book("kalshi:A").best_ask == D("0.4500") no = kalshi.book("A", side="no") @@ -472,7 +516,7 @@ def replay_poly(stream: PolymarketMarketStream, rows: list[dict], *, skip=lambda for i, row in enumerate(rows): if row["dir"] == "in" and not skip(i, row): msg = row["msg"] - stream._dispatch(msg if isinstance(msg, str) else json.dumps(msg)) + stream._process_raw_frame_and_emit_events(msg if isinstance(msg, str) else json.dumps(msg)) class TestPolymarketMarket: @@ -546,7 +590,7 @@ async def test_subscription_frames(self): def test_lifecycle_and_tick_size(self): stream = PolymarketMarketStream() - events = stream.handle([ + events = stream._handle_decoded_message([ {"event_type": "market_resolved", "market": "0xm", "winning_asset_id": "1", "timestamp": "1789659000000"}, {"event_type": "tick_size_change", "market": "0xm", "asset_id": "1", "old_tick_size": "0.01", "new_tick_size": "0.001", "timestamp": "1789659000000"}, {"event_type": "best_bid_ask", "market": "0xm", "asset_id": "1", "best_bid": "0.4", "best_ask": "0.41", "timestamp": "1789659000000"}, @@ -603,11 +647,11 @@ async def ensure_api_credentials(self): def test_orders_and_settling_fills(self): stream = PolymarketUserStream(api_key="api-key", api_secret="s", api_passphrase="p", wallets={"0xwallet"}) - placed, cancelled = stream.handle([DOC_USER_ORDER, {**DOC_USER_ORDER, "type": "CANCELLATION"}]) + placed, cancelled = stream._handle_decoded_message([DOC_USER_ORDER, {**DOC_USER_ORDER, "type": "CANCELLATION"}]) assert placed.order.status == OrderStatus.OPEN and placed.native == "PLACEMENT" assert cancelled.order.status == OrderStatus.CANCELED and cancelled.order.remaining == 0 - (matched,) = stream.handle(DOC_USER_TRADE) - (confirmed,) = stream.handle({**DOC_USER_TRADE, "status": "CONFIRMED"}) + (matched,) = stream._handle_decoded_message(DOC_USER_TRADE) + (confirmed,) = stream._handle_decoded_message({**DOC_USER_TRADE, "status": "CONFIRMED"}) assert matched.fill.id == confirmed.fill.id == "trade-1:0xorder" assert matched.fill.settlement == SettlementState.MATCHED and confirmed.fill.settlement == SettlementState.CONFIRMED assert matched.fill.quantity == D("4") and matched.fill.side == Side.SELL @@ -659,23 +703,23 @@ def test_market_data_is_a_snapshot_and_state_changes_once(self, us_creds): "bids": [{"px": {"value": "0.555", "currency": "USD"}, "qty": "0.50"}, {"px": {"value": "0.550", "currency": "USD"}, "qty": "2.50"}], "offers": [{"px": {"value": "0.560", "currency": "USD"}, "qty": "0.80"}], "state": "MARKET_STATE_OPEN", "stats": {"lastTradePx": {"value": "0.55"}}, "transactTime": "2026-09-17T12:00:00Z"}} - book, status = stream.handle(message) + book, status = stream._handle_decoded_message(message) assert isinstance(book, BookEvent) and book.kind == "snapshot" and book.best_bid == D("0.555") and book.best_ask == D("0.560") assert isinstance(status, MarketStatusEvent) and status.state == "open" - (again,) = stream.handle(message) + (again,) = stream._handle_decoded_message(message) assert isinstance(again, BookEvent) - _, halted = stream.handle({**message, "marketData": {**message["marketData"], "state": "MARKET_STATE_HALTED"}}) + _, halted = stream._handle_decoded_message({**message, "marketData": {**message["marketData"], "state": "MARKET_STATE_HALTED"}}) assert halted.state == "paused" and halted.native == "MARKET_STATE_HALTED" assert stream.book(SLUG, side="no").best_bid == D("0.440") def test_lite_trades_heartbeats_and_errors(self, us_creds): stream = PolymarketUSMarketStream(us_creds) - (quote,) = stream.handle({"marketDataLite": {"marketSlug": SLUG, "bestBid": {"value": "0.54"}, "bestAsk": {"value": "0.56"}, "lastTradePx": {"value": "0.55"}}}) - (trade,) = stream.handle({"trade": {"marketSlug": SLUG, "price": {"value": "0.555"}, "quantity": {"value": "0.50"}, + (quote,) = stream._handle_decoded_message({"marketDataLite": {"marketSlug": SLUG, "bestBid": {"value": "0.54"}, "bestAsk": {"value": "0.56"}, "lastTradePx": {"value": "0.55"}}}) + (trade,) = stream._handle_decoded_message({"trade": {"marketSlug": SLUG, "price": {"value": "0.555"}, "quantity": {"value": "0.50"}, "tradeTime": "2026-09-17T12:00:00Z", "maker": {"side": "ORDER_SIDE_BUY"}, "taker": {"side": "ORDER_SIDE_SELL"}}}) assert quote.bid == D("0.54") and trade.quantity == D("0.50") and trade.taker_side == Side.SELL - assert stream.handle({"heartbeat": {}}) == [] - assert stream.handle({"requestId": "md-1", "error": "unknown market"}) == [] + assert stream._handle_decoded_message({"heartbeat": {}}) == [] + assert stream._handle_decoded_message({"requestId": "md-1", "error": "unknown market"}) == [] (err,) = of(drain(stream), StreamStatusEvent) assert err.state == "error" and err.key == "md-1" @@ -684,25 +728,25 @@ def test_private_orders_fills_positions_balances(self, us_creds): order = {"id": "o1", "marketSlug": SLUG, "side": "ORDER_SIDE_SELL", "type": "ORDER_TYPE_LIMIT", "price": {"value": "0.17"}, "quantity": 10, "leavesQuantity": 6, "cumQuantity": 4, "state": "ORDER_STATE_PARTIALLY_FILLED", "intent": "ORDER_INTENT_BUY_SHORT", "tif": "TIME_IN_FORCE_GOOD_TILL_CANCEL"} - (snap,) = stream.handle({"requestId": "order-1", "orderSubscriptionSnapshot": {"orders": [order], "eof": True}}) + (snap,) = stream._handle_decoded_message({"requestId": "order-1", "orderSubscriptionSnapshot": {"orders": [order], "eof": True}}) assert snap.native == "snapshot" and snap.order.market_id == f"polymarket_us:{SLUG}" assert snap.order.side == Side.SELL and snap.order.price == D("0.17") - update, fill = stream.handle({"orderSubscriptionUpdate": {"execution": { + update, fill = stream._handle_decoded_message({"orderSubscriptionUpdate": {"execution": { "id": "e1", "order": order, "lastShares": "4", "lastPx": {"value": "0.16"}, "type": "EXECUTION_TYPE_PARTIAL_FILL", "tradeId": "t1", "aggressor": True, "transactTime": "2026-09-17T12:00:00Z"}}}) assert isinstance(update, OrderEvent) and update.native == "EXECUTION_TYPE_PARTIAL_FILL" assert isinstance(fill, FillEvent) and fill.fill.id == "t1" and fill.fill.price == D("0.16") and fill.fill.side == Side.SELL assert fill.fill.liquidity == Liquidity.TAKER and fill.fill.market_id == f"polymarket_us:{SLUG}" - (cancel,) = stream.handle({"orderUpdate": {"execution": {"id": "e2", "order": {**order, "state": "ORDER_STATE_CANCELED"}, "type": "EXECUTION_TYPE_CANCELED"}}}) + (cancel,) = stream._handle_decoded_message({"orderUpdate": {"execution": {"id": "e2", "order": {**order, "state": "ORDER_STATE_CANCELED"}, "type": "EXECUTION_TYPE_CANCELED"}}}) assert cancel.order.status == OrderStatus.CANCELED - (position,) = stream.handle({"positionSubscription": { + (position,) = stream._handle_decoded_message({"positionSubscription": { "marketSlug": SLUG, "beforePosition": {"netPositionDecimal": "1.0000"}, "afterPosition": {"netPositionDecimal": "-2.0000", "cost": {"value": "-0.34"}}, "updateTime": "2026-09-17T12:00:00Z", "entryType": "LEDGER_ENTRY_TYPE_ORDER_EXECUTION"}}) assert isinstance(position, PositionEvent) and position.position.side == PositionSide.SHORT and position.position.contracts == D("2") - (docs_balance,) = stream.handle({"accountBalancesSnapshot": {"balances": [{"currentBalance": 1000.0, "currency": "USD", "buyingPower": 850.0}]}}) - (sdk_balance,) = stream.handle({"accountBalanceSubscriptionUpdate": {"balance": 990.5, "buyingPower": 840.5}}) - (change,) = stream.handle({"accountBalancesUpdate": {"balanceChange": {"afterBalance": {"currentBalance": 980, "buyingPower": 830}}}}) + (docs_balance,) = stream._handle_decoded_message({"accountBalancesSnapshot": {"balances": [{"currentBalance": 1000.0, "currency": "USD", "buyingPower": 850.0}]}}) + (sdk_balance,) = stream._handle_decoded_message({"accountBalanceSubscriptionUpdate": {"balance": 990.5, "buyingPower": 840.5}}) + (change,) = stream._handle_decoded_message({"accountBalancesUpdate": {"balanceChange": {"afterBalance": {"currentBalance": 980, "buyingPower": 830}}}}) assert isinstance(docs_balance, BalanceEvent) and docs_balance.balance.buying_power == D("850.0") assert sdk_balance.balance.total == D("990.5") and change.balance.available == D("830")